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:
Ivan Kupalov 2021-06-11 20:15:40 +02:00 committed by GitHub
parent 0a992480c2
commit 44a5b42cac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 3956 additions and 3618 deletions

View file

@ -119,6 +119,8 @@ dependencies {
implementation "androidx.work:work-runtime:2.5.0"
implementation "androidx.room:room-runtime:$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"
implementation "com.google.android.material:material:1.3.0"

View file

@ -5,7 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.db.*
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.Assert.assertEquals
import org.junit.Assert.assertNull

View file

@ -5,6 +5,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
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.show
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import java.lang.Exception
import javax.inject.Inject
class FiltersActivity: BaseActivity() {
@ -162,37 +166,29 @@ class FiltersActivity: BaseActivity() {
binding.addFilterButton.hide()
binding.filterProgressBar.show()
api.getFilters().enqueue(object : Callback<List<Filter>> {
override fun onResponse(call: Call<List<Filter>>, response: Response<List<Filter>>) {
val filterResponse = response.body()
if(response.isSuccessful && filterResponse != null) {
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) {
lifecycleScope.launch {
val newFilters = try {
api.getFilters().await()
} catch (t: Exception) {
binding.filterProgressBar.hide()
binding.filterMessageView.show()
if (t is IOException) {
binding.filterMessageView.setup(R.drawable.elephant_offline,
R.string.error_network) { loadFilters() }
R.string.error_network) { loadFilters() }
} else {
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 {

View file

@ -37,7 +37,7 @@ import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
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.viewmodel.ListsViewModel
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.*
@ -182,7 +182,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun onListSelected(listId: String) {
startActivityWithSlideInAnimation(
ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId))
ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId))
}
private fun openListSettings(list: MastoList) {

View file

@ -595,7 +595,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun changeAccount(newSelectedId: Long, forward: Intent?) {
cacheUpdater.stop()
SFragment.flushFilters()
accountManager.setActiveAccount(newSelectedId)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK

View file

@ -5,7 +5,8 @@ import android.content.Intent
import android.os.Bundle
import com.google.android.material.floatingactionbutton.FloatingActionButton
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 dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
@ -29,8 +30,8 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
}
if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) {
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineFragment.Kind
?: TimelineFragment.Kind.HOME
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind
?: TimelineViewModel.Kind.HOME
val argument = intent?.getStringExtra(ARG_ARG)
supportFragmentManager.beginTransaction()
.replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument))
@ -47,7 +48,7 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
private const val ARG_ARG = "arg"
@JvmStatic
fun newIntent(context: Context, kind: TimelineFragment.Kind,
fun newIntent(context: Context, kind: TimelineViewModel.Kind,
argument: String?): Intent {
val intent = Intent(context, ModalTimelineActivity::class.java)
intent.putExtra(ARG_KIND, kind)

View file

@ -21,8 +21,8 @@ import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.fragment.TimelineFragment.Kind
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Kind
import javax.inject.Inject

View file

@ -21,7 +21,8 @@ import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
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 */
@ -47,7 +48,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
HOME,
R.string.title_home,
R.drawable.ic_home_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.HOME) }
{ TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) }
)
NOTIFICATIONS -> TabData(
NOTIFICATIONS,
@ -59,13 +60,13 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
LOCAL,
R.string.title_public_local,
R.drawable.ic_local_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) }
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) }
)
FEDERATED -> TabData(
FEDERATED,
R.string.title_public_federated,
R.drawable.ic_public_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) }
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) }
)
DIRECT -> TabData(
DIRECT,
@ -85,7 +86,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
LIST,
R.string.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.getOrNull(1).orEmpty() }
)

View file

@ -25,7 +25,7 @@ import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import com.keylesspalace.tusky.fragment.TimelineFragment;
import com.keylesspalace.tusky.components.timeline.TimelineFragment;
import java.util.Collections;

View file

@ -43,6 +43,7 @@ import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -195,14 +196,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} else {
holder.showNotificationContent(true);
holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis());
holder.setUsername(statusViewData.getNickname());
holder.setCreatedAt(statusViewData.getCreatedAt());
Status status = statusViewData.getActionable();
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if(concreteNotificaton.getType() == Notification.Type.STATUS) {
holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot());
if (concreteNotificaton.getType() == Notification.Type.STATUS) {
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else {
holder.setAvatars(statusViewData.getAvatar(),
holder.setAvatars(status.getAccount().getAvatar(),
concreteNotificaton.getAccount().getAvatar());
}
}
@ -215,7 +217,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
if (payloadForHolder instanceof List)
for (Object item : (List) payloadForHolder) {
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 SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf;
private int avatarRadius48dp;
private int avatarRadius36dp;
private int avatarRadius24dp;
@ -415,7 +417,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
statusContent.setOnClickListener(this);
shortSdf = new SimpleDateFormat("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.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
@ -531,7 +533,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
message.setText(emojifiedText);
if (statusViewData != null) {
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText());
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
if (statusViewData.isExpanded()) {
@ -586,7 +588,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
notificationAvatar.setVisibility(View.VISIBLE);
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
avatarRadius24dp, statusDisplayOptions.animateAvatars());
avatarRadius24dp, statusDisplayOptions.animateAvatars());
}
@Override
@ -607,7 +609,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private void setupContentAndSpoiler(final LinkListener listener) {
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText());
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
if (!shouldShowContentIfSpoiler && hasSpoiler) {
statusContent.setVisibility(View.GONE);
} else {
@ -615,7 +617,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
Spanned content = statusViewData.getContent();
List<Emoji> emojis = statusViewData.getStatusEmojis();
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
contentCollapseButton.setOnClickListener(view -> {
@ -641,13 +643,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener);
CharSequence emojifiedContentWarning;
if (statusViewData.getSpoilerText() != null) {
emojifiedContentWarning = CustomEmojiHelper.emojify(
statusViewData.getSpoilerText(),
statusViewData.getStatusEmojis(),
statusViewData.getActionable().getEmojis(),
contentWarningDescriptionTextView,
statusDisplayOptions.animateEmojis()
);

View file

@ -28,7 +28,7 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
private Button loadMoreButton;
private ProgressBar progressBar;
PlaceholderViewHolder(View itemView) {
public PlaceholderViewHolder(View itemView) {
super(itemView);
loadMoreButton = itemView.findViewById(R.id.button_load_more);
progressBar = itemView.findViewById(R.id.progressBar);

View file

@ -201,7 +201,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setSpoilerAndContent(boolean expanded,
@NonNull Spanned content,
@Nullable String spoilerText,
@Nullable Status.Mention[] mentions,
@Nullable List<Status.Mention> mentions,
@NonNull List<Emoji> emojis,
@Nullable PollViewData poll,
@NonNull StatusDisplayOptions statusDisplayOptions,
@ -243,7 +243,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void setTextVisible(boolean sensitive,
boolean expanded,
Spanned content,
Status.Mention[] mentions,
List<Status.Mention> mentions,
List<Emoji> emojis,
@Nullable PollViewData poll,
StatusDisplayOptions statusDisplayOptions,
@ -708,21 +708,23 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
this.setupWithStatus(status, listener, statusDisplayOptions, null);
}
protected void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions);
setUsername(status.getNickname());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null);
setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions);
setReblogged(status.isReblogged());
setFavourited(status.isFavourited());
setBookmarked(status.isBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.isSensitive();
Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getDisplayName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(status.getUsername());
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
setIsReply(actionable.getInReplyToId() != null);
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions);
setReblogged(actionable.getReblogged());
setFavourited(actionable.getFavourited());
setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = actionable.getAttachments();
boolean sensitive = actionable.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
@ -747,11 +749,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions);
}
setupButtons(listener, status.getSenderId(), status.getContent().toString(),
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
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);
@ -765,7 +770,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (payloads instanceof List)
for (Object item : (List<?>) payloads) {
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,
StatusDisplayOptions statusDisplayOptions) {
Context context = itemView.getContext();
Status actionable = status.getActionable();
String description = context.getString(R.string.description_status,
status.getUserFullName(),
actionable.getAccount().getDisplayName(),
getContentWarningDescription(context, status),
(TextUtils.isEmpty(status.getSpoilerText()) || !status.isSensitive() || status.isExpanded() ? status.getContent() : ""),
getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions),
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
getReblogDescription(context, status),
status.getNickname(),
status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "",
status.isFavourited() ? context.getString(R.string.description_status_favourited) : "",
status.isBookmarked() ? context.getString(R.string.description_status_bookmarked) : "",
status.getUsername(),
actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "",
actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "",
actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "",
getMediaDescription(context, status),
getVisibilityDescription(context, status.getVisibility()),
getFavsText(context, status.getFavouritesCount()),
getReblogsText(context, status.getReblogsCount()),
getVisibilityDescription(context, actionable.getVisibility()),
getFavsText(context, actionable.getFavouritesCount()),
getReblogsText(context, actionable.getReblogsCount()),
getPollDescription(status, context, statusDisplayOptions)
);
itemView.setContentDescription(description);
@ -806,10 +812,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getReblogDescription(Context context,
@NonNull StatusViewData.Concrete status) {
String rebloggedUsername = status.getRebloggedByUsername();
if (rebloggedUsername != null) {
Status reblog = status.getRebloggingStatus();
if (reblog != null) {
return context
.getString(R.string.status_boosted_format, rebloggedUsername);
.getString(R.string.status_boosted_format, reblog.getAccount().getUsername());
} else {
return "";
}
@ -817,11 +823,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getMediaDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (status.getAttachments().isEmpty()) {
if (status.getActionable().getAttachments().isEmpty()) {
return "";
}
StringBuilder mediaDescriptions = CollectionsKt.fold(
status.getAttachments(),
status.getActionable().getAttachments(),
new StringBuilder(),
(builder, a) -> {
if (a.getDescription() == null) {
@ -874,7 +880,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
Context context,
StatusDisplayOptions statusDisplayOptions) {
PollViewData poll = status.getPoll();
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
if (poll == null) {
return "";
} else {
@ -980,7 +986,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions,
Context context) {
String votesText;
if(poll.getVotersCount() == null) {
if (poll.getVotersCount() == null) {
String voters = numberFormat.format(poll.getVotesCount());
votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters);
} else {
@ -1004,12 +1010,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) {
final Card card = status.getActionable().getCard();
if (cardViewMode != CardViewMode.NONE &&
status.getAttachments().size() == 0 &&
status.getCard() != null &&
!TextUtils.isEmpty(status.getCard().getUrl()) &&
status.getActionable().getAttachments().size() == 0 &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
(!status.isCollapsible() || !status.isCollapsed())) {
final Card card = status.getCard();
cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle());
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,
// so let's blur the preview in that case
// 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 topRightRadius = 0;

View file

@ -101,7 +101,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
@Override
protected void setupWithStatus(final StatusViewData.Concrete status,
public void setupWithStatus(final StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
@ -110,12 +110,13 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
if (payloads == null) {
if (!statusDisplayOptions.hideStats()) {
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener);
setReblogAndFavCount(status.getActionable().getReblogsCount(),
status.getActionable().getFavouritesCount(), listener);
} else {
hideQuantitativeStats();
}
setApplication(status.getApplication());
setApplication(status.getActionable().getApplication());
View.OnLongClickListener longClickListener = view -> {
TextView textView = (TextView) view;
@ -130,7 +131,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
content.setOnLongClickListener(longClickListener);
contentWarningDescription.setOnLongClickListener(longClickListener);
setStatusVisibility(status.getVisibility());
setStatusVisibility(status.getActionable().getVisibility());
}
}

View file

@ -26,6 +26,8 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
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.util.CustomEmojiHelper;
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.viewdata.StatusViewData;
import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils;
public class StatusViewHolder extends StatusBaseViewHolder {
@ -54,19 +58,21 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
@Override
protected void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
setupCollapsedState(status, listener);
String rebloggedByDisplayName = status.getRebloggedByUsername();
if (rebloggedByDisplayName == null) {
Status reblogging = status.getRebloggingStatus();
if (reblogging == null) {
hideStatusInfo();
} else {
setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions);
String rebloggedByDisplayName = reblogging.getAccount().getDisplayName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
@ -76,13 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
private void setRebloggedByDisplayName(final CharSequence name,
final StatusViewData.Concrete status,
final List<Emoji> accountEmoji,
final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, status.getRebloggedByAccountEmojis(), statusInfo, statusDisplayOptions.animateEmojis()
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
);
statusInfo.setText(emojifiedText);
statusInfo.setVisibility(View.VISIBLE);

View file

@ -21,3 +21,4 @@ data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable
data class AnnouncementReadEvent(val announcementId: String): Dispatchable
data class PinEvent(val statusId: String, val pinned: Boolean): Dispatchable

View file

@ -73,7 +73,7 @@ data class ConversationStatusEntity(
val sensitive: Boolean,
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val mentions: Array<Status.Mention>,
val mentions: List<Status.Mention>,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
@ -101,7 +101,7 @@ data class ConversationStatusEntity(
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) 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 (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
@ -125,7 +125,7 @@ data class ConversationStatusEntity(
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.contentHashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()

View file

@ -40,6 +40,7 @@ import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import javax.inject.Inject
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?) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewMedia(attachmentIndex, it.toStatus(), view)
viewMedia(attachmentIndex, AttachmentViewData.list(it.toStatus()), view)
}
}
override fun onViewThread(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewThread(it.toStatus())
val status = it.toStatus()
viewThread(status.actionableId, status.actionableStatus.url)
}
}

View file

@ -15,17 +15,20 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject
class ConversationsViewModel @Inject constructor(
private val repository: ConversationsRepository,
private val timelineCases: TimelineCases,
private val database: AppDatabase,
private val accountManager: AccountManager
private val repository: ConversationsRepository,
private val timelineCases: TimelineCases,
private val database: AppDatabase,
private val accountManager: AccountManager
) : RxAwareViewModel() {
private val repoResult = MutableLiveData<Listing<ConversationEntity>>()
val conversations: LiveData<PagedList<ConversationEntity>> = Transformations.switchMap(repoResult) { it.pagedList }
val networkState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkState }
val refreshState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState }
val conversations: LiveData<PagedList<ConversationEntity>> =
Transformations.switchMap(repoResult) { it.pagedList }
val networkState: LiveData<NetworkState> =
Transformations.switchMap(repoResult) { it.networkState }
val refreshState: LiveData<NetworkState> =
Transformations.switchMap(repoResult) { it.refreshState }
fun load() {
val accountId = accountManager.activeAccount?.id ?: return
@ -45,57 +48,76 @@ class ConversationsViewModel @Inject constructor(
fun favourite(favourite: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.favourite(conversation.lastStatus.toStatus(), favourite)
.flatMap {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite)
)
timelineCases.favourite(conversation.lastStatus.id, favourite)
.flatMap {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t ->
Log.w(
"ConversationViewModel",
"Failed to favourite conversation",
t
)
}
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
}
}
fun bookmark(bookmark: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.bookmark(conversation.lastStatus.toStatus(), bookmark)
.flatMap {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
)
timelineCases.bookmark(conversation.lastStatus.id, bookmark)
.flatMap {
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t ->
Log.w(
"ConversationViewModel",
"Failed to bookmark conversation",
t
)
}
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
}
}
fun voteInPoll(position: Int, choices: MutableList<Int>) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices)
.flatMap { poll ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = poll)
)
val poll = conversation.lastStatus.poll ?: return
timelineCases.voteInPoll(conversation.lastStatus.id, poll.id, choices)
.flatMap { newPoll ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = newPoll)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t ->
Log.w(
"ConversationViewModel",
"Failed to favourite conversation",
t
)
}
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
}
}
@ -103,7 +125,7 @@ class ConversationsViewModel @Inject constructor(
fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(expanded = expanded)
lastStatus = conversation.lastStatus.copy(expanded = expanded)
)
saveConversationToDb(newConversation)
}
@ -112,7 +134,7 @@ class ConversationsViewModel @Inject constructor(
fun collapseLongStatus(collapsed: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
)
saveConversationToDb(newConversation)
}
@ -121,7 +143,7 @@ class ConversationsViewModel @Inject constructor(
fun showContent(showing: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
)
saveConversationToDb(newConversation)
}
@ -135,8 +157,8 @@ class ConversationsViewModel @Inject constructor(
private fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation)
.subscribeOn(Schedulers.io())
.subscribe()
.subscribeOn(Schedulers.io())
.subscribe()
}
}

View file

@ -316,7 +316,7 @@ public class NotificationHelper {
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
Status.Mention[] mentions = actionableStatus.getMentions();
List<Status.Mention> mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
@ -381,7 +381,6 @@ public class NotificationHelper {
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
//noinspection ConstantConditions
notificationManager.createNotificationChannelGroup(channelGroup);
for (int i = 0; i < channelIds.length; i++) {

View file

@ -118,7 +118,7 @@ class StatusViewHolder(
private fun setTextVisible(expanded: Boolean,
content: Spanned,
mentions: Array<Status.Mention>?,
mentions: List<Status.Mention>?,
emojis: List<Emoji>,
listener: LinkListener) {
if (expanded) {

View file

@ -15,13 +15,12 @@ import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import javax.inject.Inject
class SearchViewModel @Inject constructor(
mastodonApi: MastodonApi,
private val timelineCases: TimelineCases,
private val accountManager: AccountManager
mastodonApi: MastodonApi,
private val timelineCases: TimelineCases,
private val accountManager: AccountManager
) : RxAwareViewModel() {
var currentQuery: String = ""
@ -36,93 +35,109 @@ class SearchViewModel @Inject constructor(
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: 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 hashtagsRepository = SearchRepository<HashTag>(mastodonApi)
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 networkStateStatusRefresh: LiveData<NetworkState> = repoResultStatus.switchMap { it.refreshState }
val networkStateStatusRefresh: LiveData<NetworkState> =
repoResultStatus.switchMap { it.refreshState }
private val repoResultAccount = MutableLiveData<Listing<Account>>()
val accounts: LiveData<PagedList<Account>> = repoResultAccount.switchMap { it.pagedList }
val networkStateAccount: LiveData<NetworkState> = repoResultAccount.switchMap { it.networkState }
val networkStateAccountRefresh: LiveData<NetworkState> = repoResultAccount.switchMap { it.refreshState }
val networkStateAccount: LiveData<NetworkState> =
repoResultAccount.switchMap { it.networkState }
val networkStateAccountRefresh: LiveData<NetworkState> =
repoResultAccount.switchMap { it.refreshState }
private val repoResultHashTag = MutableLiveData<Listing<HashTag>>()
val hashtags: LiveData<PagedList<HashTag>> = repoResultHashTag.switchMap { it.pagedList }
val networkStateHashTag: LiveData<NetworkState> = repoResultHashTag.switchMap { it.networkState }
val networkStateHashTagRefresh: LiveData<NetworkState> = repoResultHashTag.switchMap { it.refreshState }
val networkStateHashTag: LiveData<NetworkState> =
repoResultHashTag.switchMap { it.networkState }
val networkStateHashTagRefresh: LiveData<NetworkState> =
repoResultHashTag.switchMap { it.refreshState }
private val loadedStatuses = ArrayList<Pair<Status, StatusViewData.Concrete>>()
fun search(query: String) {
loadedStatuses.clear()
repoResultStatus.value = statusesRepository.getSearchData(SearchType.Status, query, disposables, initialItems = loadedStatuses) {
it?.statuses?.map { status -> Pair(status, ViewDataUtils.statusToViewData(status, alwaysShowSensitiveMedia, alwaysOpenSpoiler)!!) }
.orEmpty()
.apply {
loadedStatuses.addAll(this)
}
}
repoResultAccount.value = accountsRepository.getSearchData(SearchType.Account, query, disposables) {
it?.accounts.orEmpty()
repoResultStatus.value = statusesRepository.getSearchData(
SearchType.Status,
query,
disposables,
initialItems = loadedStatuses
) {
it?.statuses?.map { status ->
Pair(
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"
repoResultHashTag.value =
hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) {
it?.hashtags.orEmpty()
}
hashtagsRepository.getSearchData(SearchType.Hashtag, hashtagQuery, disposables) {
it?.hashtags.orEmpty()
}
}
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
timelineCases.delete(status.first.id)
.subscribe({
if (loadedStatuses.remove(status))
repoResultStatus.value?.refresh?.invoke()
}, {
err -> Log.d(TAG, "Failed to delete status", err)
})
.autoDispose()
.subscribe({
if (loadedStatuses.remove(status))
repoResultStatus.value?.refresh?.invoke()
}, { err ->
Log.d(TAG, "Failed to delete status", err)
})
.autoDispose()
}
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
val idx = loadedStatuses.indexOf(status)
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
repoResultStatus.value?.refresh?.invoke()
}
}
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
timelineCases.reblog(status.first, reblog)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ setRebloggedForStatus(status, reblog) },
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
)
.autoDispose()
timelineCases.reblog(status.first.id, reblog)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ setRebloggedForStatus(status, reblog) },
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
)
.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.reblog?.reblogged = reblog
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setReblogged(reblog).createStatusViewData())
loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke()
}
repoResultStatus.value?.refresh?.invoke()
}
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(status)
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
repoResultStatus.value?.refresh?.invoke()
}
@ -131,7 +146,7 @@ class SearchViewModel @Inject constructor(
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(status)
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
repoResultStatus.value?.refresh?.invoke()
}
@ -140,54 +155,46 @@ class SearchViewModel @Inject constructor(
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
updateStatus(status, votedPoll)
timelineCases.voteInPoll(status.first, choices)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ newPoll -> updateStatus(status, newPoll) },
{ t ->
Log.d(TAG,
"Failed to vote in poll: ${status.first.id}", t)
}
)
.autoDispose()
timelineCases.voteInPoll(status.first.id, votedPoll.id, choices)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ newPoll -> updateStatus(status, newPoll) },
{ t ->
Log.d(
TAG,
"Failed to vote in poll: ${status.first.id}", t
)
}
)
.autoDispose()
}
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
val newViewData = StatusViewData.Builder(status.second)
.setPoll(newPoll)
.createStatusViewData()
loadedStatuses[idx] = Pair(status.first, newViewData)
val newStatus = status.first.copy(poll = newPoll)
val newViewData = status.second.copy(status = newStatus)
loadedStatuses[idx] = Pair(newStatus, newViewData)
repoResultStatus.value?.refresh?.invoke()
}
}
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setFavourited(isFavorited).createStatusViewData())
loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke()
}
timelineCases.favourite(status.first, isFavorited)
.onErrorReturnItem(status.first)
.subscribe()
.autoDispose()
status.first.favourited = isFavorited
repoResultStatus.value?.refresh?.invoke()
timelineCases.favourite(status.first.id, isFavorited)
.onErrorReturnItem(status.first)
.subscribe()
.autoDispose()
}
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
val idx = loadedStatuses.indexOf(status)
if (idx >= 0) {
val newPair = Pair(status.first, StatusViewData.Builder(status.second).setBookmarked(isBookmarked).createStatusViewData())
loadedStatuses[idx] = newPair
repoResultStatus.value?.refresh?.invoke()
}
timelineCases.bookmark(status.first, isBookmarked)
.onErrorReturnItem(status.first)
.subscribe()
.autoDispose()
status.first.bookmarked = isBookmarked
repoResultStatus.value?.refresh?.invoke()
timelineCases.bookmark(status.first.id, isBookmarked)
.onErrorReturnItem(status.first)
.subscribe()
.autoDispose()
}
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
@ -199,7 +206,7 @@ class SearchViewModel @Inject constructor(
}
fun pinAccount(status: Status, isPin: Boolean) {
timelineCases.pin(status, isPin)
timelineCases.pin(status.id, isPin)
}
fun blockAccount(accountId: String) {
@ -217,14 +224,18 @@ class SearchViewModel @Inject constructor(
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
val idx = loadedStatuses.indexOf(status)
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
repoResultStatus.value?.refresh?.invoke()
}
timelineCases.muteConversation(status.first, mute)
.onErrorReturnItem(status.first)
.subscribe()
.autoDispose()
timelineCases.muteConversation(status.first.id, mute)
.onErrorReturnItem(status.first)
.subscribe()
.autoDispose()
}
companion object {

View file

@ -52,7 +52,7 @@ class SearchStatusesAdapter(
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
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 =
oldItem.second.id == newItem.second.id

View file

@ -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 {
account?.username == it.username && account.domain == Uri.parse(it.url)?.host
} != null

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter;
package com.keylesspalace.tusky.components.timeline;
import android.view.LayoutInflater;
import android.view.View;
@ -24,6 +24,8 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
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.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.StatusViewData;

View file

@ -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
}
}
}
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -105,13 +105,13 @@ class Converters @Inject constructor (
}
@TypeConverter
fun mentionArrayToJson(mentionArray: Array<Status.Mention>?): String? {
fun mentionListToJson(mentionArray: List<Status.Mention>?): String? {
return gson.toJson(mentionArray)
}
@TypeConverter
fun jsonToMentionArray(mentionListJson: String?): Array<Status.Mention>? {
return gson.fromJson(mentionListJson, object : TypeToken<Array<Status.Mention>>() {}.type)
fun jsonToMentionArray(mentionListJson: String?): List<Status.Mention>? {
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type)
}
@TypeConverter

View file

@ -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.SearchStatusesFragment
import com.keylesspalace.tusky.components.preference.PreferencesFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector

View file

@ -4,8 +4,8 @@ import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRepository
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
import com.keylesspalace.tusky.components.timeline.TimelineRepository
import com.keylesspalace.tusky.components.timeline.TimelineRepositoryImpl
import dagger.Module
import dagger.Provides

View file

@ -11,6 +11,8 @@ import com.keylesspalace.tusky.components.drafts.DraftsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel
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.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
@ -97,5 +99,10 @@ abstract class ViewModelModule {
@ViewModelKey(DraftsViewModel::class)
internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(TimelineViewModel::class)
internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel
//Add more ViewModels here
}

View file

@ -22,10 +22,11 @@ import com.google.gson.JsonParseException
import com.google.gson.annotations.JsonAdapter
data class Notification(
val type: Type,
val id: String,
val account: Account,
val status: Status?) {
val type: Type,
val id: String,
val account: Account,
val status: Status?
) {
@JsonAdapter(NotificationTypeAdapter::class)
enum class Type(val presentation: String) {
@ -71,18 +72,25 @@ data class Notification(
class NotificationTypeAdapter : JsonDeserializer<Type> {
@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)
}
}
/** Helper for Java */
fun copyWithStatus(status: Status?): Notification = copy(status = status)
// for Pleroma compatibility that uses Mention type
fun rewriteToStatusTypeIfNeeded(accountId: String) : Notification {
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
if (type == Type.MENTION && status != null) {
return if (status.mentions.any {
it.id == accountId
}) this else copy(type = Type.STATUS)
it.id == accountId
}) this else copy(type = Type.STATUS)
}
return this
}

View file

@ -22,8 +22,8 @@ import com.google.gson.annotations.SerializedName
import java.util.*
data class Status(
var id: String,
var url: String?, // not present if it's reblog
val id: String,
val url: String?, // not present if it's reblog
val account: Account,
@SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
@ -40,10 +40,10 @@ data class Status(
@SerializedName("spoiler_text") val spoilerText: String,
val visibility: Visibility,
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
val mentions: Array<Mention>,
val mentions: List<Mention>,
val application: Application?,
var pinned: Boolean?,
var muted: Boolean?,
val pinned: Boolean?,
val muted: Boolean?,
val poll: Poll?,
val card: Card?
) {
@ -54,6 +54,11 @@ data class Status(
val actionableStatus: Status
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) {
UNKNOWN(0),

View file

@ -58,6 +58,7 @@ import com.keylesspalace.tusky.appstore.BlockEvent;
import com.keylesspalace.tusky.appstore.BookmarkEvent;
import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.FavoriteEvent;
import com.keylesspalace.tusky.appstore.PinEvent;
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent;
import com.keylesspalace.tusky.appstore.ReblogEvent;
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.view.BackgroundMessageView;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
@ -92,6 +94,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@ -311,35 +314,6 @@ public class NotificationsFragment extends SFragment implements
.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
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
@ -386,11 +360,13 @@ public class NotificationsFragment extends SFragment implements
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(event -> {
if (event instanceof FavoriteEvent) {
handleFavEvent((FavoriteEvent) event);
setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite());
} else if (event instanceof BookmarkEvent) {
handleBookmarkEvent((BookmarkEvent) event);
setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark());
} 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) {
removeAllByAccountId(((BlockEvent) event).getAccountId());
} else if (event instanceof PreferenceChangedEvent) {
@ -423,34 +399,21 @@ public class NotificationsFragment extends SFragment implements
final Notification notification = notifications.get(position).asRight();
final Status status = notification.getStatus();
Objects.requireNonNull(status, "Reblog on notification without status");
timelineCases.reblog(status, reblog)
timelineCases.reblog(status.getId(), reblog)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> setReblogForStatus(position, status, reblog),
(newStatus) -> setReblogForStatus(status.getId(), reblog),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to reblog status: " + status.getId(), t)
);
}
private void setReblogForStatus(int position, Status status, boolean reblog) {
status.setReblogged(reblog);
if (status.getReblog() != null) {
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();
private void setReblogForStatus(String statusId, boolean reblog) {
updateStatus(statusId, (s) -> {
s.setReblogged(reblog);
return s;
});
}
@Override
@ -458,34 +421,21 @@ public class NotificationsFragment extends SFragment implements
final Notification notification = notifications.get(position).asRight();
final Status status = notification.getStatus();
timelineCases.favourite(status, favourite)
timelineCases.favourite(status.getId(), favourite)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> setFavouriteForStatus(position, status, favourite),
(newStatus) -> setFavouriteForStatus(status.getId(), favourite),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to favourite status: " + status.getId(), t)
);
}
private void setFavouriteForStatus(int position, Status status, boolean favourite) {
status.setFavourited(favourite);
if (status.getReblog() != null) {
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();
private void setFavouriteForStatus(String statusId, boolean favourite) {
updateStatus(statusId, (s) -> {
s.setFavourited(favourite);
return s;
});
}
@Override
@ -493,63 +443,38 @@ public class NotificationsFragment extends SFragment implements
final Notification notification = notifications.get(position).asRight();
final Status status = notification.getStatus();
timelineCases.bookmark(status, bookmark)
timelineCases.bookmark(status.getActionableId(), bookmark)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> setBookmarkForStatus(position, status, bookmark),
(newStatus) -> setBookmarkForStatus(status.getId(), bookmark),
(t) -> Log.d(getClass().getSimpleName(),
"Failed to bookmark status: " + status.getId(), t)
);
}
private void setBookmarkForStatus(int position, Status status, boolean bookmark) {
status.setBookmarked(bookmark);
if (status.getReblog() != null) {
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();
private void setBookmarkForStatus(String statusId, boolean bookmark) {
updateStatus(statusId, (s) -> {
s.setBookmarked(bookmark);
return s;
});
}
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Notification notification = notifications.get(position).asRight();
final Status status = notification.getStatus();
timelineCases.voteInPoll(status, choices)
final Status status = notification.getStatus().getActionableStatus();
timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, newPoll),
(newPoll) -> setVoteForPoll(status, newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Poll 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();
private void setVoteForPoll(Status status, Poll poll) {
updateStatus(status.getId(), (s) -> s.copyWithPoll(poll));
}
@Override
@ -562,13 +487,17 @@ public class NotificationsFragment extends SFragment implements
public void onViewMedia(int position, int attachmentIndex, @Nullable View view) {
Notification notification = notifications.get(position).asRightOrNull();
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
public void onViewThread(int position) {
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
@ -579,30 +508,19 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onExpandedChange(boolean expanded, int position) {
NotificationViewData.Concrete old =
(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();
updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded));
}
@Override
public void onContentHiddenChange(boolean isShowing, int position) {
NotificationViewData.Concrete old =
(NotificationViewData.Concrete) notifications.getPairedItem(position);
StatusViewData.Concrete statusViewData =
new StatusViewData.Builder(old.getStatusViewData())
.setIsShowingSensitiveContent(isShowing)
.createStatusViewData();
NotificationViewData notificationViewData = new NotificationViewData.Concrete(old.getType(),
old.getId(), old.getAccount(), statusViewData);
notifications.setPairedItem(position, notificationViewData);
updateAdapter();
updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing));
}
private void setPinForStatus(String statusId, boolean pinned) {
updateStatus(statusId, status -> {
status.copyWithPinned(pinned);
return status;
});
}
@Override
@ -628,42 +546,74 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onContentCollapsedChange(boolean isCollapsed, int position) {
if (position < 0 || position >= notifications.size()) {
Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, notifications.size() - 1));
return;
}
updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed));
;
}
NotificationViewData notification = notifications.getPairedItem(position);
if (!(notification instanceof NotificationViewData.Concrete)) {
Log.e(TAG, String.format(
"Expected NotificationViewData.Concrete, got %s instead at position: %d of %d",
notification == null ? "null" : notification.getClass().getSimpleName(),
private void updateStatus(String statusId, Function<Status, Status> mapper) {
int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() &&
s.asRight().getStatus() != null &&
s.asRight().getStatus().getId().equals(statusId));
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,
notifications.size() - 1
));
);
Log.e(TAG, message);
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();
StatusViewData.Concrete updatedStatus = new StatusViewData.Builder(status)
.setCollapsed(isCollapsed)
.createStatusViewData();
NotificationViewData.Concrete newViewData =
oldViewData.copyWithStatus(mapper.apply(oldStatusViewData));
notifications.setPairedItem(position, newViewData);
NotificationViewData.Concrete concreteNotification = (NotificationViewData.Concrete) notification;
NotificationViewData updatedNotification = new NotificationViewData.Concrete(
concreteNotification.getType(),
concreteNotification.getId(),
concreteNotification.getAccount(),
updatedStatus
);
notifications.setPairedItem(position, updatedNotification);
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
@ -844,8 +794,11 @@ public class NotificationsFragment extends SFragment implements
for (Either<Placeholder, Notification> either : notifications) {
Notification notification = either.asRightOrNull();
if (notification != null && notification.getId().equals(notificationId)) {
super.viewThread(notification.getStatus());
return;
Status status = notification.getStatus();
if (status != null) {
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
return;
}
}
}
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)
.observeOn(AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
response -> {

View file

@ -24,7 +24,6 @@ import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Environment;
import android.text.TextUtils;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
@ -33,7 +32,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.PopupMenu;
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.di.Injectable;
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.network.MastodonApi;
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.viewdata.AttachmentViewData;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.inject.Inject;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
@ -96,11 +86,6 @@ public abstract class SFragment extends Fragment implements Injectable {
private BottomSheetActivity bottomSheetActivity;
private static List<Filter> filters;
private boolean filterRemoveRegex;
private Matcher filterRemoveRegexMatcher;
private static final Matcher alphanumeric = Pattern.compile("^\\w+$").matcher("");
@Inject
public MastodonApi mastodonApi;
@Inject
@ -131,9 +116,8 @@ public abstract class SFragment extends Fragment implements Injectable {
bottomSheetActivity.viewAccount(status.getAccount().getId());
}
protected void viewThread(Status status) {
Status actionableStatus = status.getActionableStatus();
bottomSheetActivity.viewThread(actionableStatus.getId(), actionableStatus.getUrl());
protected void viewThread(String statusId, @Nullable String statusUrl) {
bottomSheetActivity.viewThread(statusId, statusUrl);
}
protected void viewAccount(String accountId) {
@ -149,7 +133,7 @@ public abstract class SFragment extends Fragment implements Injectable {
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
Status.Mention[] mentions = actionableStatus.getMentions();
List<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> mentionedUsernames = new LinkedHashSet<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
String loggedInUsername = null;
@ -316,11 +300,11 @@ public abstract class SFragment extends Fragment implements Injectable {
return true;
}
case R.id.pin: {
timelineCases.pin(status, !status.isPinned());
timelineCases.pin(status.getId(), !status.isPinned());
return true;
}
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)
.observeOn(AndroidSchedulers.mainThread())
.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) {
MuteAccountDialog.showMuteAccountDialog(
this.getActivity(),
accountUsername,
(notifications, duration) -> {
timelineCases.mute(accountId, notifications, duration);
return Unit.INSTANCE;
}
this.getActivity(),
accountUsername,
(notifications, duration) -> {
timelineCases.mute(accountId, notifications, duration);
return Unit.INSTANCE;
}
);
}
@ -352,7 +336,7 @@ public abstract class SFragment extends Fragment implements Injectable {
.show();
}
private static boolean accountIsInMentions(AccountEntity account, Status.Mention[] mentions) {
private static boolean accountIsInMentions(AccountEntity account, List<Status.Mention> mentions) {
if (account == null) {
return false;
}
@ -368,20 +352,18 @@ public abstract class SFragment extends Fragment implements Injectable {
return false;
}
protected void viewMedia(int urlIndex, Status status, @Nullable View view) {
final Status actionable = status.getActionableStatus();
final Attachment active = actionable.getAttachments().get(urlIndex);
Attachment.Type type = active.getType();
protected void viewMedia(int urlIndex, List<AttachmentViewData> attachments, @Nullable View view) {
final AttachmentViewData active = attachments.get(urlIndex);
Attachment.Type type = active.getAttachment().getType();
switch (type) {
case GIFV:
case VIDEO:
case IMAGE:
case AUDIO: {
final List<AttachmentViewData> attachments = AttachmentViewData.list(actionable);
final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments,
urlIndex);
if (view != null) {
String url = active.getUrl();
String url = active.getAttachment().getUrl();
ViewCompat.setTransitionName(view, url);
ActivityOptionsCompat options =
ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(),
@ -394,7 +376,7 @@ public abstract class SFragment extends Fragment implements Injectable {
}
default:
case UNKNOWN: {
LinkHelper.openLink(active.getUrl(), getContext());
LinkHelper.openLink(active.getAttachment().getUrl(), getContext());
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;
}
}

View file

@ -28,7 +28,6 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.arch.core.util.Function;
import androidx.core.util.Pair;
import androidx.lifecycle.Lifecycle;
import androidx.preference.PreferenceManager;
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.EventHub;
import com.keylesspalace.tusky.appstore.FavoriteEvent;
import com.keylesspalace.tusky.appstore.PinEvent;
import com.keylesspalace.tusky.appstore.ReblogEvent;
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
@ -56,6 +56,7 @@ import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.FilterModel;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.CardViewMode;
@ -64,6 +65,7 @@ import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ViewDataUtils;
import com.keylesspalace.tusky.view.ConversationLineItemDecoration;
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.ArrayList;
@ -73,7 +75,9 @@ import java.util.Locale;
import javax.inject.Inject;
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import kotlin.collections.CollectionsKt;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
@ -86,6 +90,8 @@ public final class ViewThreadFragment extends SFragment implements
public MastodonApi mastodonApi;
@Inject
public EventHub eventHub;
@Inject
public FilterModel filterModel;
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
@ -163,7 +169,7 @@ public final class ViewThreadFragment extends SFragment implements
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context));
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
reloadFilters(false);
reloadFilters();
recyclerView.setAdapter(adapter);
@ -190,6 +196,8 @@ public final class ViewThreadFragment extends SFragment implements
handleReblogEvent((ReblogEvent) event);
} else if (event instanceof BookmarkEvent) {
handleBookmarkEvent((BookmarkEvent) event);
} else if (event instanceof PinEvent) {
handlePinEvent(((PinEvent) event));
} else if (event instanceof BlockEvent) {
removeAllByAccountId(((BlockEvent) event).getAccountId());
} else if (event instanceof StatusComposedEvent) {
@ -203,13 +211,8 @@ public final class ViewThreadFragment extends SFragment implements
public void onRevealPressed() {
boolean allExpanded = allExpanded();
for (int i = 0; i < statuses.size(); i++) {
StatusViewData.Concrete newViewData =
new StatusViewData.Concrete.Builder(statuses.getPairedItem(i))
.setIsExpanded(!allExpanded)
.createStatusViewData();
statuses.setPairedItem(i, newViewData);
updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded));
}
adapter.setStatuses(statuses.getPairedCopy());
updateRevealIcon();
}
@ -239,11 +242,11 @@ public final class ViewThreadFragment extends SFragment implements
public void onReblog(final boolean reblog, final int position) {
final Status status = statuses.get(position);
timelineCases.reblog(statuses.get(position), reblog)
timelineCases.reblog(statuses.get(position).getId(), reblog)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> updateStatus(position, newStatus),
this::replaceStatus,
(t) -> Log.d(TAG,
"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) {
final Status status = statuses.get(position);
timelineCases.favourite(statuses.get(position), favourite)
timelineCases.favourite(statuses.get(position).getId(), favourite)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> updateStatus(position, newStatus),
this::replaceStatus,
(t) -> Log.d(TAG,
"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) {
final Status status = statuses.get(position);
timelineCases.bookmark(statuses.get(position), bookmark)
timelineCases.bookmark(statuses.get(position).getId(), bookmark)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newStatus) -> updateStatus(position, newStatus),
this::replaceStatus,
(t) -> Log.d(TAG,
"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()) {
Status actionableStatus = status.getActionableStatus();
StatusViewData.Concrete viewData = new StatusViewData.Builder(statuses.getPairedItem(position))
.setReblogged(actionableStatus.getReblogged())
.setReblogsCount(actionableStatus.getReblogsCount())
.setFavourited(actionableStatus.getFavourited())
.setBookmarked(actionableStatus.getBookmarked())
.setFavouritesCount(actionableStatus.getFavouritesCount())
.createStatusViewData();
statuses.setPairedItem(position, viewData);
adapter.setItem(position, viewData, true);
Status oldStatus = statuses.get(position);
Status newStatus = mapper.apply(oldStatus);
StatusViewData.Concrete oldViewData = statuses.getPairedItem(position);
statuses.set(position, newStatus);
updateViewData(position, oldViewData.copyWithStatus(newStatus));
}
}
@ -304,7 +304,7 @@ public final class ViewThreadFragment extends SFragment implements
@Override
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
Status status = statuses.get(position);
super.viewMedia(attachmentIndex, status, view);
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view);
}
@Override
@ -314,7 +314,7 @@ public final class ViewThreadFragment extends SFragment implements
// If already viewing this thread, don't reopen it.
return;
}
super.viewThread(status);
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
}
@Override
@ -325,21 +325,22 @@ public final class ViewThreadFragment extends SFragment implements
@Override
public void onExpandedChange(boolean expanded, int position) {
StatusViewData.Concrete newViewData =
new StatusViewData.Builder(statuses.getPairedItem(position))
.setIsExpanded(expanded)
.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
updateViewData(
position,
statuses.getPairedItem(position).copyWithExpanded(expanded)
);
updateRevealIcon();
}
@Override
public void onContentHiddenChange(boolean isShowing, int position) {
StatusViewData.Concrete newViewData =
new StatusViewData.Builder(statuses.getPairedItem(position))
.setIsShowingSensitiveContent(isShowing)
.createStatusViewData();
updateViewData(
position,
statuses.getPairedItem(position).copyWithShowingContent(isShowing)
);
}
private void updateViewData(int position, StatusViewData.Concrete newViewData) {
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
@ -365,28 +366,11 @@ public final class ViewThreadFragment extends SFragment implements
@Override
public void onContentCollapsedChange(boolean isCollapsed, int position) {
if (position < 0 || position >= statuses.size()) {
Log.e(TAG, String.format("Tried to access out of bounds status position: %d of %d", position, statuses.size() - 1));
return;
}
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));
adapter.setItem(
position,
statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed),
true
);
}
@Override
@ -412,28 +396,21 @@ public final class ViewThreadFragment extends SFragment implements
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
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())
.to(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(position, newPoll),
(newPoll) -> setVoteForPoll(status.getId(), newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(int position, Poll 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 setVoteForPoll(String statusId, Poll newPoll) {
updateStatus(statusId, s -> s.copyWithPoll(newPoll));
}
private void removeAllByAccountId(String accountId) {
@ -530,7 +507,7 @@ public final class ViewThreadFragment extends SFragment implements
ArrayList<Status> ancestors = new ArrayList<>();
for (Status status : unfilteredAncestors)
if (!shouldFilterStatus(status))
if (!filterModel.shouldFilterStatus(status))
ancestors.add(status);
// Insert newly fetched ancestors
@ -560,7 +537,7 @@ public final class ViewThreadFragment extends SFragment implements
ArrayList<Status> descendants = new ArrayList<>();
for (Status status : unfilteredDescendants)
if (!shouldFilterStatus(status))
if (!filterModel.shouldFilterStatus(status))
descendants.add(status);
// Insert newly fetched descendants
@ -581,71 +558,31 @@ public final class ViewThreadFragment extends SFragment implements
}
private void handleFavEvent(FavoriteEvent event) {
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
if (posAndStatus == null) return;
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);
updateStatus(event.getStatusId(), (s) -> {
s.setFavourited(event.getFavourite());
return s;
});
}
private void handleReblogEvent(ReblogEvent event) {
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
if (posAndStatus == null) return;
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);
updateStatus(event.getStatusId(), (s) -> {
s.setReblogged(event.getReblog());
return s;
});
}
private void handleBookmarkEvent(BookmarkEvent event) {
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
if (posAndStatus == null) return;
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);
updateStatus(event.getStatusId(), (s) -> {
s.setBookmarked(event.getBookmark());
return s;
});
}
private void handlePinEvent(PinEvent event) {
updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned()));
}
private void handleStatusComposedEvent(StatusComposedEvent event) {
Status eventStatus = event.getStatus();
if (eventStatus.getInReplyToId() == null) return;
@ -671,23 +608,16 @@ public final class ViewThreadFragment extends SFragment implements
}
private void handleStatusDeletedEvent(StatusDeletedEvent event) {
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
if (posAndStatus == null) return;
@SuppressWarnings("ConstantConditions")
int pos = posAndStatus.first;
statuses.remove(pos);
adapter.removeItem(pos);
int index = this.indexOfStatus(event.getStatusId());
if (index != -1) {
statuses.remove(index);
adapter.removeItem(index);
}
}
@Nullable
private Pair<Integer, Status> findStatusAndPos(@NonNull String statusId) {
for (int i = 0; i < statuses.size(); i++) {
if (statusId.equals(statuses.get(i).getId())) {
return new Pair<>(i, statuses.get(i));
}
}
return null;
private int indexOfStatus(String statusId) {
return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId));
}
private void updateRevealIcon() {
@ -710,13 +640,25 @@ public final class ViewThreadFragment extends SFragment implements
ViewThreadActivity.REVEAL_BUTTON_REVEAL);
}
@Override
protected boolean filterIsRelevant(@NonNull Filter filter) {
return filter.getContext().contains(Filter.THREAD);
private void reloadFilters() {
mastodonApi.getFilters()
.to(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(
(filters) -> {
List<Filter> relevantFilters = CollectionsKt.filter(
filters,
(f) -> f.getContext().contains(Filter.THREAD)
);
filterModel.initWithFilters(relevantFilters);
recyclerView.post(this::applyFilters);
},
(t) -> Log.e(TAG, "Failed to load filters", t)
);
}
@Override
protected void refreshAfterApplyingFilters() {
onRefresh();
private void applyFilters() {
CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus);
adapter.setStatuses(this.statuses.getPairedCopy());
}
}

View file

@ -38,7 +38,7 @@ public interface StatusActionListener extends LinkListener {
void onOpenReblog(int position);
void onExpandedChange(boolean expanded, 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

View file

@ -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+$")
}
}

View file

@ -49,7 +49,7 @@ interface MastodonApi {
fun getInstance(): Single<Instance>
@GET("api/v1/filters")
fun getFilters(): Call<List<Filter>>
fun getFilters(): Single<List<Filter>>
@GET("api/v1/timelines/home")
fun homeTimeline(

View file

@ -30,20 +30,20 @@ import java.lang.IllegalStateException
*/
interface TimelineCases {
fun reblog(status: Status, reblog: Boolean): Single<Status>
fun favourite(status: Status, favourite: Boolean): Single<Status>
fun bookmark(status: Status, bookmark: Boolean): Single<Status>
fun mute(id: String, notifications: Boolean, duration: Int?)
fun block(id: String)
fun delete(id: String): Single<DeletedStatus>
fun pin(status: Status, pin: Boolean)
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
fun muteConversation(status: Status, mute: Boolean): Single<Status>
fun reblog(statusId: String, reblog: Boolean): Single<Status>
fun favourite(statusId: String, favourite: Boolean): Single<Status>
fun bookmark(statusId: String, bookmark: Boolean): Single<Status>
fun mute(statusId: String, notifications: Boolean, duration: Int?)
fun block(statusId: String)
fun delete(statusId: String): Single<DeletedStatus>
fun pin(statusId: String, pin: Boolean): Single<Status>
fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll>
fun muteConversation(statusId: String, mute: Boolean): Single<Status>
}
class TimelineCasesImpl(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : TimelineCases {
/**
@ -52,103 +52,92 @@ class TimelineCasesImpl(
*/
private val cancelDisposable = CompositeDisposable()
override fun reblog(status: Status, reblog: Boolean): Single<Status> {
val id = status.actionableId
override fun reblog(statusId: String, reblog: Boolean): Single<Status> {
val call = if (reblog) {
mastodonApi.reblogStatus(id)
mastodonApi.reblogStatus(statusId)
} else {
mastodonApi.unreblogStatus(id)
mastodonApi.unreblogStatus(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(ReblogEvent(status.id, reblog))
eventHub.dispatch(ReblogEvent(statusId, reblog))
}
}
override fun favourite(status: Status, favourite: Boolean): Single<Status> {
val id = status.actionableId
override fun favourite(statusId: String, favourite: Boolean): Single<Status> {
val call = if (favourite) {
mastodonApi.favouriteStatus(id)
mastodonApi.favouriteStatus(statusId)
} else {
mastodonApi.unfavouriteStatus(id)
mastodonApi.unfavouriteStatus(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(FavoriteEvent(status.id, favourite))
eventHub.dispatch(FavoriteEvent(statusId, favourite))
}
}
override fun bookmark(status: Status, bookmark: Boolean): Single<Status> {
val id = status.actionableId
override fun bookmark(statusId: String, bookmark: Boolean): Single<Status> {
val call = if (bookmark) {
mastodonApi.bookmarkStatus(id)
mastodonApi.bookmarkStatus(statusId)
} else {
mastodonApi.unbookmarkStatus(id)
mastodonApi.unbookmarkStatus(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(BookmarkEvent(status.id, bookmark))
eventHub.dispatch(BookmarkEvent(statusId, bookmark))
}
}
override fun muteConversation(status: Status, mute: Boolean): Single<Status> {
val id = status.actionableId
override fun muteConversation(statusId: String, mute: Boolean): Single<Status> {
val call = if (mute) {
mastodonApi.muteConversation(id)
mastodonApi.muteConversation(statusId)
} else {
mastodonApi.unmuteConversation(id)
mastodonApi.unmuteConversation(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(MuteConversationEvent(status.id, mute))
eventHub.dispatch(MuteConversationEvent(statusId, mute))
}
}
override fun mute(id: String, notifications: Boolean, duration: Int?) {
mastodonApi.muteAccount(id, notifications, duration)
.subscribe({
eventHub.dispatch(MuteEvent(id))
}, { t ->
Log.w("Failed to mute account", t)
})
.addTo(cancelDisposable)
override fun mute(statusId: String, notifications: Boolean, duration: Int?) {
mastodonApi.muteAccount(statusId, notifications, duration)
.subscribe({
eventHub.dispatch(MuteEvent(statusId))
}, { t ->
Log.w("Failed to mute account", t)
})
.addTo(cancelDisposable)
}
override fun block(id: String) {
mastodonApi.blockAccount(id)
.subscribe({
eventHub.dispatch(BlockEvent(id))
}, { t ->
Log.w("Failed to block account", t)
})
.addTo(cancelDisposable)
override fun block(statusId: String) {
mastodonApi.blockAccount(statusId)
.subscribe({
eventHub.dispatch(BlockEvent(statusId))
}, { t ->
Log.w("Failed to block account", t)
})
.addTo(cancelDisposable)
}
override fun delete(id: String): Single<DeletedStatus> {
return mastodonApi.deleteStatus(id)
.doAfterSuccess {
eventHub.dispatch(StatusDeletedEvent(id))
}
override fun delete(statusId: String): Single<DeletedStatus> {
return mastodonApi.deleteStatus(statusId)
.doAfterSuccess {
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
(if (pin) mastodonApi.pinStatus(status.id) else mastodonApi.unpinStatus(status.id))
.subscribe({ updatedStatus ->
status.pinned = updatedStatus.pinned
}, {})
.addTo(this.cancelDisposable)
return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId))
.doAfterSuccess {
eventHub.dispatch(PinEvent(statusId, pin))
}
}
override fun voteInPoll(status: Status, choices: List<Int>): Single<Poll> {
val pollId = status.actionableStatus.poll?.id
if(pollId == null || choices.isEmpty()) {
override fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
if (choices.isEmpty()) {
return Single.error(IllegalStateException())
}
return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess {
eventHub.dispatch(PollVoteEvent(status.id, it))
eventHub.dispatch(PollVoteEvent(statusId, it))
}
}

View file

@ -18,7 +18,8 @@ package com.keylesspalace.tusky.pager
import androidx.fragment.app.*
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.util.CustomFragmentStateAdapter
@ -32,9 +33,9 @@ class AccountPagerAdapter(
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId, false)
2 -> TimelineFragment.newInstance(TimelineFragment.Kind.USER_PINNED, accountId, false)
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
3 -> AccountMediaFragment.newInstance(accountId, false)
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
}

View file

@ -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)

View file

@ -40,6 +40,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
public class LinkHelper {
public static String getDomain(String urlString) {
@ -69,7 +70,7 @@ public class LinkHelper {
* @param listener to notify about particular spans that are clicked
*/
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);
URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class);
for (URLSpan span : urlSpans) {
@ -85,7 +86,7 @@ public class LinkHelper {
@Override
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();
/* 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
@ -141,8 +142,8 @@ public class LinkHelper {
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableMentions(
TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) {
if (mentions == null || mentions.length == 0) {
TextView view, @Nullable List<Status.Mention> mentions, final LinkListener listener) {
if (mentions == null || mentions.size() == 0) {
view.setText(null);
return;
}

View file

@ -27,9 +27,9 @@ fun interface StatusProvider {
}
class ListStatusAccessibilityDelegate(
private val recyclerView: RecyclerView,
private val statusActionListener: StatusActionListener,
private val statusProvider: StatusProvider
private val recyclerView: RecyclerView,
private val statusActionListener: StatusActionListener,
private val statusProvider: StatusProvider
) : RecyclerViewAccessibilityDelegate(recyclerView) {
private val a11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE)
as AccessibilityManager
@ -39,8 +39,10 @@ class ListStatusAccessibilityDelegate(
private val context: Context get() = recyclerView.context
private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) {
override fun onInitializeAccessibilityNodeInfo(host: View,
info: AccessibilityNodeInfoCompat) {
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfoCompat
) {
super.onInitializeAccessibilityNodeInfo(host, info)
val pos = recyclerView.getChildAdapterPosition(host)
@ -52,44 +54,51 @@ class ListStatusAccessibilityDelegate(
info.addAction(replyAction)
if (status.rebloggingEnabled) {
info.addAction(if (status.isReblogged) unreblogAction else reblogAction)
val actionable = status.actionable
if (actionable.rebloggingAllowed()) {
info.addAction(if (actionable.reblogged) unreblogAction else reblogAction)
}
info.addAction(if (status.isFavourited) unfavouriteAction else favouriteAction)
info.addAction(if (status.isBookmarked) unbookmarkAction else bookmarkAction)
info.addAction(if (actionable.favourited) unfavouriteAction else favouriteAction)
info.addAction(if (actionable.bookmarked) unbookmarkAction else bookmarkAction)
val mediaActions = intArrayOf(
R.id.action_open_media_1,
R.id.action_open_media_2,
R.id.action_open_media_3,
R.id.action_open_media_4)
val attachmentCount = min(status.attachments.size, MAX_MEDIA_ATTACHMENTS)
R.id.action_open_media_1,
R.id.action_open_media_2,
R.id.action_open_media_3,
R.id.action_open_media_4
)
val attachmentCount = min(actionable.attachments.size, MAX_MEDIA_ATTACHMENTS)
for (i in 0 until attachmentCount) {
info.addAction(AccessibilityActionCompat(
info.addAction(
AccessibilityActionCompat(
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)
if (getLinks(status).any()) info.addAction(linksAction)
val mentions = status.mentions
if (mentions != null && mentions.isNotEmpty()) info.addAction(mentionsAction)
val mentions = actionable.mentions
if (mentions.isNotEmpty()) info.addAction(mentionsAction)
if (getHashtags(status).any()) info.addAction(hashtagsAction)
if (!status.rebloggedByUsername.isNullOrEmpty()) {
if (!status.status.reblog?.account?.username.isNullOrEmpty()) {
info.addAction(openRebloggerAction)
}
if (status.reblogsCount > 0) info.addAction(openRebloggedByAction)
if (status.favouritesCount > 0) info.addAction(openFavsAction)
if (actionable.reblogsCount > 0) info.addAction(openRebloggedByAction)
if (actionable.favouritesCount > 0) info.addAction(openFavsAction)
info.addAction(moreAction)
}
}
override fun performAccessibilityAction(host: View, action: Int,
args: Bundle?): Boolean {
override fun performAccessibilityAction(
host: View, action: Int,
args: Bundle?
): Boolean {
val pos = recyclerView.getChildAdapterPosition(host)
when (action) {
R.id.action_reply -> {
@ -105,7 +114,8 @@ class ListStatusAccessibilityDelegate(
R.id.action_open_profile -> {
interrupt()
statusActionListener.onViewAccount(
(statusProvider.getStatus(pos) as StatusViewData.Concrete).senderId)
(statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id
)
}
R.id.action_open_media_1 -> {
interrupt()
@ -166,43 +176,51 @@ class ListStatusAccessibilityDelegate(
val links = getLinks(status).toList()
val textLinks = links.map { item -> item.link }
AlertDialog.Builder(host.context)
.setTitle(R.string.title_links_dialog)
.setAdapter(ArrayAdapter(
host.context,
android.R.layout.simple_list_item_1,
textLinks)
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
.show()
.let { forceFocus(it.listView) }
.setTitle(R.string.title_links_dialog)
.setAdapter(
ArrayAdapter(
host.context,
android.R.layout.simple_list_item_1,
textLinks
)
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
.show()
.let { forceFocus(it.listView) }
}
private fun showMentionsDialog(host: View) {
val status = getStatus(host) as? StatusViewData.Concrete ?: return
val mentions = status.mentions ?: return
val mentions = status.actionable.mentions
val stringMentions = mentions.map { it.username }
AlertDialog.Builder(host.context)
.setTitle(R.string.title_mentions_dialog)
.setAdapter(ArrayAdapter<CharSequence>(host.context,
android.R.layout.simple_list_item_1, stringMentions)
) { _, which ->
statusActionListener.onViewAccount(mentions[which].id)
}
.show()
.let { forceFocus(it.listView) }
.setTitle(R.string.title_mentions_dialog)
.setAdapter(
ArrayAdapter<CharSequence>(
host.context,
android.R.layout.simple_list_item_1, stringMentions
)
) { _, which ->
statusActionListener.onViewAccount(mentions[which].id)
}
.show()
.let { forceFocus(it.listView) }
}
private fun showHashtagsDialog(host: View) {
val status = getStatus(host) as? StatusViewData.Concrete ?: return
val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList()
AlertDialog.Builder(host.context)
.setTitle(R.string.title_hashtags_dialog)
.setAdapter(ArrayAdapter(host.context,
android.R.layout.simple_list_item_1, tags)
) { _, which ->
statusActionListener.onViewTag(tags[which].toString())
}
.show()
.let { forceFocus(it.listView) }
.setTitle(R.string.title_hashtags_dialog)
.setAdapter(
ArrayAdapter(
host.context,
android.R.layout.simple_list_item_1, tags
)
) { _, which ->
statusActionListener.onViewTag(tags[which].toString())
}
.show()
.let { forceFocus(it.listView) }
}
private fun getStatus(childView: View): StatusViewData {
@ -215,14 +233,15 @@ class ListStatusAccessibilityDelegate(
val content = status.content
return if (content is Spannable) {
content.getSpans(0, content.length, URLSpan::class.java)
.asSequence()
.map { span ->
val text = content.subSequence(
content.getSpanStart(span),
content.getSpanEnd(span))
if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url)
}
.filterNotNull()
.asSequence()
.map { span ->
val text = content.subSequence(
content.getSpanStart(span),
content.getSpanEnd(span)
)
if (isHashtag(text)) null else LinkSpanInfo(text.toString(), span.url)
}
.filterNotNull()
} else {
emptySequence()
}
@ -231,11 +250,11 @@ class ListStatusAccessibilityDelegate(
private fun getHashtags(status: StatusViewData.Concrete): Sequence<CharSequence> {
val content = status.content
return content.getSpans(0, content.length, Object::class.java)
.asSequence()
.map { span ->
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
}
.filter(this::isHashtag)
.asSequence()
.map { span ->
content.subSequence(content.getSpanStart(span), content.getSpanEnd(span))
}
.filter(this::isHashtag)
}
private fun forceFocus(host: View) {
@ -253,72 +272,88 @@ class ListStatusAccessibilityDelegate(
private fun isHashtag(text: CharSequence) = text.startsWith("#")
private val collapseCwAction = AccessibilityActionCompat(
R.id.action_collapse_cw,
context.getString(R.string.status_content_warning_show_less))
R.id.action_collapse_cw,
context.getString(R.string.status_content_warning_show_less)
)
private val expandCwAction = AccessibilityActionCompat(
R.id.action_expand_cw,
context.getString(R.string.status_content_warning_show_more))
R.id.action_expand_cw,
context.getString(R.string.status_content_warning_show_more)
)
private val replyAction = AccessibilityActionCompat(
R.id.action_reply,
context.getString(R.string.action_reply))
R.id.action_reply,
context.getString(R.string.action_reply)
)
private val unreblogAction = AccessibilityActionCompat(
R.id.action_unreblog,
context.getString(R.string.action_unreblog))
R.id.action_unreblog,
context.getString(R.string.action_unreblog)
)
private val reblogAction = AccessibilityActionCompat(
R.id.action_reblog,
context.getString(R.string.action_reblog))
R.id.action_reblog,
context.getString(R.string.action_reblog)
)
private val unfavouriteAction = AccessibilityActionCompat(
R.id.action_unfavourite,
context.getString(R.string.action_unfavourite))
R.id.action_unfavourite,
context.getString(R.string.action_unfavourite)
)
private val favouriteAction = AccessibilityActionCompat(
R.id.action_favourite,
context.getString(R.string.action_favourite))
R.id.action_favourite,
context.getString(R.string.action_favourite)
)
private val bookmarkAction = AccessibilityActionCompat(
R.id.action_bookmark,
context.getString(R.string.action_bookmark))
R.id.action_bookmark,
context.getString(R.string.action_bookmark)
)
private val unbookmarkAction = AccessibilityActionCompat(
R.id.action_unbookmark,
context.getString(R.string.action_bookmark))
R.id.action_unbookmark,
context.getString(R.string.action_bookmark)
)
private val openProfileAction = AccessibilityActionCompat(
R.id.action_open_profile,
context.getString(R.string.action_view_profile))
R.id.action_open_profile,
context.getString(R.string.action_view_profile)
)
private val linksAction = AccessibilityActionCompat(
R.id.action_links,
context.getString(R.string.action_links))
R.id.action_links,
context.getString(R.string.action_links)
)
private val mentionsAction = AccessibilityActionCompat(
R.id.action_mentions,
context.getString(R.string.action_mentions))
R.id.action_mentions,
context.getString(R.string.action_mentions)
)
private val hashtagsAction = AccessibilityActionCompat(
R.id.action_hashtags,
context.getString(R.string.action_hashtags))
R.id.action_hashtags,
context.getString(R.string.action_hashtags)
)
private val openRebloggerAction = AccessibilityActionCompat(
R.id.action_open_reblogger,
context.getString(R.string.action_open_reblogger))
R.id.action_open_reblogger,
context.getString(R.string.action_open_reblogger)
)
private val openRebloggedByAction = AccessibilityActionCompat(
R.id.action_open_reblogged_by,
context.getString(R.string.action_open_reblogged_by))
R.id.action_open_reblogged_by,
context.getString(R.string.action_open_reblogged_by)
)
private val openFavsAction = AccessibilityActionCompat(
R.id.action_open_faved_by,
context.getString(R.string.action_open_faved_by))
R.id.action_open_faved_by,
context.getString(R.string.action_open_faved_by)
)
private val moreAction = AccessibilityActionCompat(
R.id.action_more,
context.getString(R.string.action_more)
R.id.action_more,
context.getString(R.string.action_more)
)
private data class LinkSpanInfo(val text: String, val link: String)

View file

@ -52,4 +52,8 @@ inline fun <T> List<T>.replacedFirstWhich(replacement: T, predicate: (T) -> Bool
newList[index] = replacement
}
return newList
}
inline fun <reified R> Iterable<*>.firstIsInstanceOrNull(): R? {
return firstOrNull { it is R }?.let { it as R }
}

View file

@ -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 {
var i = length
do {

View file

@ -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
)
);
}
}

View file

@ -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)
)
}

View file

@ -47,13 +47,13 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie
val dividerBottom: Int
if (current != null) {
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
} else {
child.top + avatarMargin
}
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) {
child.bottom
} else {

View file

@ -19,12 +19,5 @@ data class AttachmentViewData(
AttachmentViewData(it, actionable.id, actionable.url!!)
}
}
fun list(attachments: List<Attachment>): List<AttachmentViewData> {
return attachments.map {
AttachmentViewData(it, it.id, it.url)
}
}
}
}

View file

@ -86,9 +86,7 @@ public abstract class NotificationViewData {
return type == concrete.type &&
Objects.equals(id, concrete.id) &&
account.getId().equals(concrete.account.getId()) &&
(statusViewData == concrete.statusViewData ||
statusViewData != null &&
statusViewData.deepEquals(concrete.statusViewData));
(Objects.equals(statusViewData, concrete.statusViewData));
}
@Override
@ -96,6 +94,10 @@ public abstract class NotificationViewData {
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 {

View file

@ -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);
}
}
}

View file

@ -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
}

View file

@ -91,7 +91,7 @@ class BottomSheetActivityTest {
"",
Status.Visibility.PUBLIC,
ArrayList(),
arrayOf(),
listOf(),
null,
pinned = false,
muted = false,

View file

@ -1,260 +1,186 @@
package com.keylesspalace.tusky
import android.os.Bundle
import android.text.SpannedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption
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.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import okhttp3.Request
import okio.Timeout
import io.reactivex.rxjava3.core.Single
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.robolectric.Robolectric
import org.robolectric.annotation.Config
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.*
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class FilterTest {
private val fragment = FakeFragment()
lateinit var filterModel: FilterModel
@Before
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)
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)
filterModel.initWithFilters(filters)
}
@Test
fun shouldNotFilter() {
assertFalse(fragment.shouldFilterStatus(
assertFalse(
filterModel.shouldFilterStatus(
mockStatus(content = "should not be filtered")
))
}
@Test
fun shouldNotFilter_whenContextDoesNotMatch() {
assertFalse(fragment.shouldFilterStatus(
mockStatus(content = "one two wrongContext three")
))
)
)
}
@Test
fun shouldFilter_whenContentMatchesBadWord() {
assertTrue(fragment.shouldFilterStatus(
assertTrue(
filterModel.shouldFilterStatus(
mockStatus(content = "one two badWord three")
))
)
)
}
@Test
fun shouldFilter_whenContentMatchesBadWordPart() {
assertTrue(fragment.shouldFilterStatus(
assertTrue(
filterModel.shouldFilterStatus(
mockStatus(content = "one two badWordPart three")
))
)
)
}
@Test
fun shouldFilter_whenContentMatchesBadWholeWord() {
assertTrue(fragment.shouldFilterStatus(
assertTrue(
filterModel.shouldFilterStatus(
mockStatus(content = "one two badWholeWord three")
))
)
)
}
@Test
fun shouldNotFilter_whenContentDoesNotMatchWholeWord() {
assertFalse(fragment.shouldFilterStatus(
assertFalse(
filterModel.shouldFilterStatus(
mockStatus(content = "one two badWholeWordTest three")
))
)
)
}
@Test
fun shouldFilter_whenSpoilerTextDoesMatch() {
assertTrue(fragment.shouldFilterStatus(
assertTrue(
filterModel.shouldFilterStatus(
mockStatus(
content = "should not be filtered",
spoilerText = "badWord should be filtered"
content = "should not be filtered",
spoilerText = "badWord should be filtered"
)
))
)
)
}
@Test
fun shouldFilter_whenPollTextDoesMatch() {
assertTrue(fragment.shouldFilterStatus(
assertTrue(
filterModel.shouldFilterStatus(
mockStatus(
content = "should not be filtered",
spoilerText = "should not be filtered",
pollOptions = listOf("should not be filtered", "badWord")
content = "should not be filtered",
spoilerText = "should not be filtered",
pollOptions = listOf("should not be filtered", "badWord")
)
))
)
)
}
@Test
fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() {
assertTrue(fragment.shouldFilterStatus(
assertTrue(
filterModel.shouldFilterStatus(
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
)
)
}
}
class FakeActivity: BottomSheetActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
class FakeFragment: SFragment() {
override fun removeItem(position: Int) {
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 = 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)
}
}

View file

@ -1,4 +1,4 @@
package com.keylesspalace.tusky.fragment
package com.keylesspalace.tusky.components.timeline
import android.text.SpannableString
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.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.*
import com.keylesspalace.tusky.util.Either
import com.nhaarman.mockitokotlin2.isNull
import com.nhaarman.mockitokotlin2.verify
@ -54,10 +53,10 @@ class TimelineRepositoryTest {
private val limit = 30
private val account = AccountEntity(
id = 2,
accessToken = "token",
domain = "domain.com",
isActive = true
id = 2,
accessToken = "token",
domain = "domain.com",
isActive = true
)
@Before
@ -74,13 +73,13 @@ class TimelineRepositoryTest {
@Test
fun testNetworkUnbounded() {
val statuses = listOf(
makeStatus("3"),
makeStatus("2")
makeStatus("3"),
makeStatus("2")
)
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)
.blockingGet()
.blockingGet()
assertEquals(statuses.map(Status::lift), result)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
@ -90,9 +89,9 @@ class TimelineRepositoryTest {
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
for (status in statuses) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).cleanup(anyLong())
@ -102,34 +101,38 @@ class TimelineRepositoryTest {
@Test
fun testNetworkLoadingTopNoGap() {
val response = listOf(
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
assertEquals(
response.subList(0, 2).map(Status::lift),
result
response.subList(0, 2).map(Status::lift),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
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
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
response.last().id)
verify(timelineDao).removeAllPlaceholdersBetween(
account.id, response.first().id,
response.last().id
)
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
}
@ -137,16 +140,18 @@ class TimelineRepositoryTest {
@Test
fun testNetworkLoadingTopWithGap() {
val response = listOf(
makeStatus("5"),
makeStatus("4")
makeStatus("5"),
makeStatus("4")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
val placeholder = Placeholder("3")
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)
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
@ -174,36 +179,40 @@ class TimelineRepositoryTest {
// 1
val response = listOf(
makeStatus("5"),
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
makeStatus("5"),
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
val maxId = "3"
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
assertEquals(
response.subList(0, response.lastIndex).map(Status::lift),
result
response.subList(0, response.lastIndex).map(Status::lift),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
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
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
response.last().id)
verify(timelineDao).removeAllPlaceholdersBetween(
account.id, response.first().id,
response.last().id
)
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
}
@ -218,23 +227,25 @@ class TimelineRepositoryTest {
// 1
val response = listOf(
makeStatus("6"),
makeStatus("5"),
makeStatus("4")
makeStatus("6"),
makeStatus("5"),
makeStatus("4")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
val maxId = "4"
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
.thenReturn(Single.just(Response.success(response)))
val result = subject.getStatuses(
maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK
)
.blockingGet()
val placeholder = Placeholder("3")
assertEquals(
response.map(Status::lift) + Either.Left(placeholder),
result
response.map(Status::lift) + Either.Left(placeholder),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
// 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) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
status.toEntity(account.id, gson),
status.account.toEntity(account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
response.last().id)
verify(timelineDao).removeAllPlaceholdersBetween(
account.id, response.first().id,
response.last().id
)
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
verify(timelineDao).cleanup(anyLong())
verifyNoMoreInteractions(timelineDao)
@ -265,11 +278,11 @@ class TimelineRepositoryTest {
dbResult.account = status.account.toEntity(account.id, gson)
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))
.thenReturn(Single.just(listOf(dbResult)))
.thenReturn(Single.just(listOf(dbResult)))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
.blockingGet()
.blockingGet()
assertEquals(listOf(status, dbStatus).map(Status::lift), result)
}
@ -283,60 +296,60 @@ class TimelineRepositoryTest {
dbResult2.status = Placeholder("1").toEntity(account.id)
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))
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
.blockingGet()
.blockingGet()
assertEquals(listOf(status).map(Status::lift), result)
}
}
private fun makeStatus(id: String, account: Account = makeAccount(id)): Status {
return Status(
id = id,
account = account,
content = SpannableString("hello$id"),
createdAt = Date(),
emojis = listOf(),
reblogsCount = 3,
favouritesCount = 5,
sensitive = false,
visibility = Status.Visibility.PUBLIC,
spoilerText = "",
reblogged = true,
favourited = false,
bookmarked = false,
attachments = ArrayList(),
mentions = arrayOf(),
application = null,
inReplyToAccountId = null,
inReplyToId = null,
pinned = false,
muted = false,
reblog = null,
url = "http://example.com/statuses/$id",
poll = null,
card = null
)
}
fun makeAccount(id: String): Account {
return Account(
id = id,
localUsername = "test$id",
username = "test$id@example.com",
displayName = "Example Account $id",
note = SpannableString("Note! $id"),
url = "https://example.com/@test$id",
avatar = "avatar$id",
header = "Header$id",
followersCount = 300,
followingCount = 400,
statusesCount = 1000,
bot = false,
emojis = listOf(),
fields = null,
source = null
)
}
private fun makeAccount(id: String): Account {
return Account(
id = id,
localUsername = "test$id",
username = "test$id@example.com",
displayName = "Example Account $id",
note = SpannableString("Note! $id"),
url = "https://example.com/@test$id",
avatar = "avatar$id",
header = "Header$id",
followersCount = 300,
followingCount = 400,
statusesCount = 1000,
bot = false,
emojis = listOf(),
fields = null,
source = null
)
}
}
fun makeStatus(id: String, account: Account = makeAccount(id)): Status {
return Status(
id = id,
account = account,
content = SpannableString("hello$id"),
createdAt = Date(),
emojis = listOf(),
reblogsCount = 3,
favouritesCount = 5,
sensitive = false,
visibility = Status.Visibility.PUBLIC,
spoilerText = "",
reblogged = true,
favourited = false,
bookmarked = false,
attachments = ArrayList(),
mentions = listOf(),
application = null,
inReplyToAccountId = null,
inReplyToId = null,
pinned = false,
muted = false,
reblog = null,
url = "http://example.com/statuses/$id",
poll = null,
card = null
)
}

View file

@ -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) }
}