Machine translation of posts (#4307)
This commit is contained in:
parent
80982d061e
commit
fbb22799dc
File diff suppressed because it is too large
Load Diff
|
@ -55,7 +55,7 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
accountManager.activeAccount?.let { account ->
|
accountManager.activeAccount?.let { account ->
|
||||||
val instanceInfo = instanceInfoRepository.getInstanceInfo()
|
val instanceInfo = instanceInfoRepository.getUpdatedInstanceInfoOrFallback()
|
||||||
binding.accountInfo.text = getString(
|
binding.accountInfo.text = getString(
|
||||||
R.string.about_account_info,
|
R.string.about_account_info,
|
||||||
account.username,
|
account.username,
|
||||||
|
|
|
@ -48,6 +48,7 @@ import com.keylesspalace.tusky.entity.Filter;
|
||||||
import com.keylesspalace.tusky.entity.FilterResult;
|
import com.keylesspalace.tusky.entity.FilterResult;
|
||||||
import com.keylesspalace.tusky.entity.HashTag;
|
import com.keylesspalace.tusky.entity.HashTag;
|
||||||
import com.keylesspalace.tusky.entity.Status;
|
import com.keylesspalace.tusky.entity.Status;
|
||||||
|
import com.keylesspalace.tusky.entity.Translation;
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||||
import com.keylesspalace.tusky.util.AttachmentHelper;
|
import com.keylesspalace.tusky.util.AttachmentHelper;
|
||||||
|
@ -56,6 +57,7 @@ import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||||
import com.keylesspalace.tusky.util.LinkHelper;
|
import com.keylesspalace.tusky.util.LinkHelper;
|
||||||
|
import com.keylesspalace.tusky.util.LocaleUtilsKt;
|
||||||
import com.keylesspalace.tusky.util.NumberUtils;
|
import com.keylesspalace.tusky.util.NumberUtils;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||||
|
@ -66,11 +68,13 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData;
|
||||||
import com.keylesspalace.tusky.viewdata.PollViewData;
|
import com.keylesspalace.tusky.viewdata.PollViewData;
|
||||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData;
|
||||||
|
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
import at.connyduck.sparkbutton.SparkButton;
|
import at.connyduck.sparkbutton.SparkButton;
|
||||||
import at.connyduck.sparkbutton.helpers.Utils;
|
import at.connyduck.sparkbutton.helpers.Utils;
|
||||||
|
@ -120,6 +124,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
protected final TextView filteredPlaceholderLabel;
|
protected final TextView filteredPlaceholderLabel;
|
||||||
protected final Button filteredPlaceholderShowButton;
|
protected final Button filteredPlaceholderShowButton;
|
||||||
protected final ConstraintLayout statusContainer;
|
protected final ConstraintLayout statusContainer;
|
||||||
|
private final TextView translationStatusView;
|
||||||
|
private final Button untranslateButton;
|
||||||
|
|
||||||
|
|
||||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||||
|
@ -182,6 +189,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
||||||
((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false);
|
((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false);
|
||||||
|
|
||||||
|
translationStatusView = itemView.findViewById(R.id.status_translation_status);
|
||||||
|
untranslateButton = itemView.findViewById(R.id.status_button_untranslate);
|
||||||
|
|
||||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||||
|
@ -213,7 +223,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
final @NonNull StatusActionListener listener) {
|
final @NonNull StatusActionListener listener) {
|
||||||
|
|
||||||
Status actionable = status.getActionable();
|
Status actionable = status.getActionable();
|
||||||
String spoilerText = actionable.getSpoilerText();
|
String spoilerText = status.getSpoilerText();
|
||||||
List<Emoji> emojis = actionable.getEmojis();
|
List<Emoji> emojis = actionable.getEmojis();
|
||||||
|
|
||||||
boolean sensitive = !TextUtils.isEmpty(spoilerText);
|
boolean sensitive = !TextUtils.isEmpty(spoilerText);
|
||||||
|
@ -273,7 +283,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
List<Status.Mention> mentions = actionable.getMentions();
|
List<Status.Mention> mentions = actionable.getMentions();
|
||||||
List<HashTag> tags = actionable.getTags();
|
List<HashTag> tags = actionable.getTags();
|
||||||
List<Emoji> emojis = actionable.getEmojis();
|
List<Emoji> emojis = actionable.getEmojis();
|
||||||
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
|
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
||||||
|
@ -779,7 +789,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
setReblogged(actionable.getReblogged());
|
setReblogged(actionable.getReblogged());
|
||||||
setFavourited(actionable.getFavourited());
|
setFavourited(actionable.getFavourited());
|
||||||
setBookmarked(actionable.getBookmarked());
|
setBookmarked(actionable.getBookmarked());
|
||||||
List<Attachment> attachments = actionable.getAttachments();
|
List<Attachment> attachments = status.getAttachments();
|
||||||
boolean sensitive = actionable.getSensitive();
|
boolean sensitive = actionable.getSensitive();
|
||||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||||
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
|
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
|
||||||
|
@ -802,6 +812,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
|
||||||
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
||||||
statusDisplayOptions);
|
statusDisplayOptions);
|
||||||
|
|
||||||
|
setTranslationStatus(status, listener);
|
||||||
|
|
||||||
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
||||||
|
|
||||||
setSpoilerAndContent(status, statusDisplayOptions, listener);
|
setSpoilerAndContent(status, statusDisplayOptions, listener);
|
||||||
|
@ -827,6 +840,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setTranslationStatus(StatusViewData.Concrete status, StatusActionListener listener) {
|
||||||
|
var translationViewData = status.getTranslation();
|
||||||
|
if (translationViewData != null) {
|
||||||
|
if (translationViewData instanceof TranslationViewData.Loaded) {
|
||||||
|
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
|
||||||
|
translationStatusView.setVisibility(View.VISIBLE);
|
||||||
|
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
|
||||||
|
translationStatusView.setText(translationStatusView.getContext().getString(R.string.label_translated, langName, translation.getProvider()));
|
||||||
|
untranslateButton.setVisibility(View.VISIBLE);
|
||||||
|
untranslateButton.setOnClickListener((v) -> listener.onUntranslate(getBindingAdapterPosition()));
|
||||||
|
} else {
|
||||||
|
translationStatusView.setVisibility(View.VISIBLE);
|
||||||
|
translationStatusView.setText(R.string.label_translating);
|
||||||
|
untranslateButton.setVisibility(View.GONE);
|
||||||
|
untranslateButton.setOnClickListener(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
translationStatusView.setVisibility(View.GONE);
|
||||||
|
untranslateButton.setVisibility(View.GONE);
|
||||||
|
untranslateButton.setOnClickListener(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
|
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
|
||||||
if (status.getFilterAction() != Filter.Action.WARN) {
|
if (status.getFilterAction() != Filter.Action.WARN) {
|
||||||
showFilteredPlaceholder(false);
|
showFilteredPlaceholder(false);
|
||||||
|
@ -864,27 +900,57 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
Status actionable = status.getActionable();
|
Status actionable = status.getActionable();
|
||||||
|
|
||||||
String description = context.getString(R.string.description_status,
|
String description = context.getString(R.string.description_status,
|
||||||
|
// 1 display_name
|
||||||
actionable.getAccount().getDisplayName(),
|
actionable.getAccount().getDisplayName(),
|
||||||
|
// 2 CW?
|
||||||
getContentWarningDescription(context, status),
|
getContentWarningDescription(context, status),
|
||||||
(TextUtils.isEmpty(actionable.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
// 3 content?
|
||||||
|
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||||
|
// 4 date
|
||||||
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||||
|
// 5 edited?
|
||||||
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
||||||
|
// 6 reposted_by?
|
||||||
getReblogDescription(context, status),
|
getReblogDescription(context, status),
|
||||||
|
// 7 username
|
||||||
actionable.getAccount().getUsername(),
|
actionable.getAccount().getUsername(),
|
||||||
|
// 8 reposted
|
||||||
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
|
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
|
||||||
|
// 9 favorited
|
||||||
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
|
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
|
||||||
|
// 10 bookmarked
|
||||||
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
|
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
|
||||||
|
// 11 media
|
||||||
getMediaDescription(context, status),
|
getMediaDescription(context, status),
|
||||||
|
// 12 visibility
|
||||||
getVisibilityDescription(context, actionable.getVisibility()),
|
getVisibilityDescription(context, actionable.getVisibility()),
|
||||||
|
// 13 fav_number
|
||||||
getFavsText(context, actionable.getFavouritesCount()),
|
getFavsText(context, actionable.getFavouritesCount()),
|
||||||
|
// 14 reblog_number
|
||||||
getReblogsText(context, actionable.getReblogsCount()),
|
getReblogsText(context, actionable.getReblogsCount()),
|
||||||
getPollDescription(status, context, statusDisplayOptions)
|
// 15 poll?
|
||||||
|
getPollDescription(status, context, statusDisplayOptions),
|
||||||
|
// 16 translated?
|
||||||
|
getTranslatedDescription(context, status.getTranslation())
|
||||||
);
|
);
|
||||||
itemView.setContentDescription(description);
|
itemView.setContentDescription(description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getTranslatedDescription(Context context, TranslationViewData translationViewData) {
|
||||||
|
if (translationViewData == null) {
|
||||||
|
return "";
|
||||||
|
} else if (translationViewData instanceof TranslationViewData.Loading) {
|
||||||
|
return context.getString(R.string.label_translating);
|
||||||
|
} else {
|
||||||
|
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
|
||||||
|
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
|
||||||
|
return context.getString(R.string.label_translated, langName, translation.getProvider());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static CharSequence getReblogDescription(Context context,
|
private static CharSequence getReblogDescription(Context context,
|
||||||
@NonNull StatusViewData.Concrete status) {
|
@NonNull StatusViewData.Concrete status) {
|
||||||
|
@Nullable
|
||||||
Status reblog = status.getRebloggingStatus();
|
Status reblog = status.getRebloggingStatus();
|
||||||
if (reblog != null) {
|
if (reblog != null) {
|
||||||
return context
|
return context
|
||||||
|
@ -895,12 +961,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CharSequence getMediaDescription(Context context,
|
private static CharSequence getMediaDescription(Context context,
|
||||||
@NonNull StatusViewData.Concrete status) {
|
@NonNull StatusViewData.Concrete viewData) {
|
||||||
if (status.getActionable().getAttachments().isEmpty()) {
|
if (viewData.getAttachments().isEmpty()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
StringBuilder mediaDescriptions = CollectionsKt.fold(
|
StringBuilder mediaDescriptions = CollectionsKt.fold(
|
||||||
status.getActionable().getAttachments(),
|
viewData.getAttachments(),
|
||||||
new StringBuilder(),
|
new StringBuilder(),
|
||||||
(builder, a) -> {
|
(builder, a) -> {
|
||||||
if (a.getDescription() == null) {
|
if (a.getDescription() == null) {
|
||||||
|
@ -917,8 +983,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
|
||||||
private static CharSequence getContentWarningDescription(Context context,
|
private static CharSequence getContentWarningDescription(Context context,
|
||||||
@NonNull StatusViewData.Concrete status) {
|
@NonNull StatusViewData.Concrete status) {
|
||||||
if (!TextUtils.isEmpty(status.getActionable().getSpoilerText())) {
|
if (!TextUtils.isEmpty(status.getSpoilerText())) {
|
||||||
return context.getString(R.string.description_post_cw, status.getActionable().getSpoilerText());
|
return context.getString(R.string.description_post_cw, status.getSpoilerText());
|
||||||
} else {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
@ -954,7 +1020,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
|
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
|
||||||
Context context,
|
Context context,
|
||||||
StatusDisplayOptions statusDisplayOptions) {
|
StatusDisplayOptions statusDisplayOptions) {
|
||||||
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
|
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
|
||||||
if (poll == null) {
|
if (poll == null) {
|
||||||
return "";
|
return "";
|
||||||
} else {
|
} else {
|
||||||
|
@ -981,7 +1047,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
protected CharSequence getReblogsText (@NonNull Context context, int count) {
|
protected CharSequence getReblogsText(@NonNull Context context, int count) {
|
||||||
String countString = numberFormat.format(count);
|
String countString = numberFormat.format(count);
|
||||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,13 @@ import android.text.method.LinkMovementMethod;
|
||||||
import android.text.style.DynamicDrawableSpan;
|
import android.text.style.DynamicDrawableSpan;
|
||||||
import android.text.style.ImageSpan;
|
import android.text.style.ImageSpan;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
import android.widget.Button;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
|
import androidx.appcompat.widget.ViewUtils;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
import com.keylesspalace.tusky.R;
|
import com.keylesspalace.tusky.R;
|
||||||
|
@ -23,6 +25,7 @@ import com.keylesspalace.tusky.util.CardViewMode;
|
||||||
import com.keylesspalace.tusky.util.LinkHelper;
|
import com.keylesspalace.tusky.util.LinkHelper;
|
||||||
import com.keylesspalace.tusky.util.NoUnderlineURLSpan;
|
import com.keylesspalace.tusky.util.NoUnderlineURLSpan;
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||||
|
import com.keylesspalace.tusky.util.ViewExtensionsKt;
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||||
|
|
||||||
import java.text.DateFormat;
|
import java.text.DateFormat;
|
||||||
|
|
|
@ -80,7 +80,7 @@ class ComposeViewModel @Inject constructor(
|
||||||
private var currentContent: String? = ""
|
private var currentContent: String? = ""
|
||||||
private var currentContentWarning: String? = ""
|
private var currentContentWarning: String? = ""
|
||||||
|
|
||||||
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
|
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
|
||||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||||
|
|
||||||
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
|
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
|
||||||
|
|
|
@ -136,7 +136,11 @@ class ConversationsFragment :
|
||||||
|
|
||||||
if (loadState.isAnyLoading()) {
|
if (loadState.isAnyLoading()) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
eventHub.dispatch(ConversationsLoadingEvent(accountManager.activeAccount?.accountId ?: ""))
|
eventHub.dispatch(
|
||||||
|
ConversationsLoadingEvent(
|
||||||
|
accountManager.activeAccount?.accountId ?: ""
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,12 +157,14 @@ class ConversationsFragment :
|
||||||
binding.statusView.showHelp(R.string.help_empty_conversations)
|
binding.statusView.showHelp(R.string.help_empty_conversations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is LoadState.Error -> {
|
is LoadState.Error -> {
|
||||||
binding.statusView.show()
|
binding.statusView.show()
|
||||||
binding.statusView.setup(
|
binding.statusView.setup(
|
||||||
(loadState.refresh as LoadState.Error).error
|
(loadState.refresh as LoadState.Error).error
|
||||||
) { refreshContent() }
|
) { refreshContent() }
|
||||||
}
|
}
|
||||||
|
|
||||||
is LoadState.Loading -> {
|
is LoadState.Loading -> {
|
||||||
binding.progressBar.show()
|
binding.progressBar.show()
|
||||||
}
|
}
|
||||||
|
@ -242,6 +248,7 @@ class ConversationsFragment :
|
||||||
refreshContent()
|
refreshContent()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -256,7 +263,8 @@ class ConversationsFragment :
|
||||||
|
|
||||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||||
|
|
||||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
binding.recyclerView.adapter =
|
||||||
|
adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshContent() {
|
private fun refreshContent() {
|
||||||
|
@ -284,6 +292,8 @@ class ConversationsFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
|
||||||
|
|
||||||
override fun onMore(view: View, position: Int) {
|
override fun onMore(view: View, position: Int) {
|
||||||
adapter.peek(position)?.let { conversation ->
|
adapter.peek(position)?.let { conversation ->
|
||||||
|
|
||||||
|
@ -386,6 +396,10 @@ class ConversationsFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUntranslate(position: Int) {
|
||||||
|
// not needed
|
||||||
|
}
|
||||||
|
|
||||||
private fun deleteConversation(conversation: ConversationViewData) {
|
private fun deleteConversation(conversation: ConversationViewData) {
|
||||||
AlertDialog.Builder(requireContext())
|
AlertDialog.Builder(requireContext())
|
||||||
.setMessage(R.string.dialog_delete_conversation_warning)
|
.setMessage(R.string.dialog_delete_conversation_warning)
|
||||||
|
@ -402,6 +416,7 @@ class ConversationsFragment :
|
||||||
PrefKeys.FAB_HIDE -> {
|
PrefKeys.FAB_HIDE -> {
|
||||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||||
|
|
|
@ -29,5 +29,6 @@ data class InstanceInfo(
|
||||||
val maxFields: Int,
|
val maxFields: Int,
|
||||||
val maxFieldNameLength: Int?,
|
val maxFieldNameLength: Int?,
|
||||||
val maxFieldValueLength: Int?,
|
val maxFieldValueLength: Int?,
|
||||||
val version: String?
|
val version: String?,
|
||||||
|
val translationEnabled: Boolean?,
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,28 +16,64 @@
|
||||||
package com.keylesspalace.tusky.components.instanceinfo
|
package com.keylesspalace.tusky.components.instanceinfo
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.getOrElse
|
import at.connyduck.calladapter.networkresult.getOrElse
|
||||||
|
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||||
|
import at.connyduck.calladapter.networkresult.map
|
||||||
import at.connyduck.calladapter.networkresult.onSuccess
|
import at.connyduck.calladapter.networkresult.onSuccess
|
||||||
|
import at.connyduck.calladapter.networkresult.recover
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.db.AppDatabase
|
import com.keylesspalace.tusky.db.AppDatabase
|
||||||
import com.keylesspalace.tusky.db.EmojisEntity
|
import com.keylesspalace.tusky.db.EmojisEntity
|
||||||
import com.keylesspalace.tusky.db.InstanceInfoEntity
|
import com.keylesspalace.tusky.db.InstanceInfoEntity
|
||||||
|
import com.keylesspalace.tusky.di.ApplicationScope
|
||||||
import com.keylesspalace.tusky.entity.Emoji
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
|
import com.keylesspalace.tusky.entity.Instance
|
||||||
|
import com.keylesspalace.tusky.entity.InstanceV1
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Singleton
|
||||||
class InstanceInfoRepository @Inject constructor(
|
class InstanceInfoRepository @Inject constructor(
|
||||||
private val api: MastodonApi,
|
private val api: MastodonApi,
|
||||||
db: AppDatabase,
|
db: AppDatabase,
|
||||||
accountManager: AccountManager
|
private val accountManager: AccountManager,
|
||||||
|
@ApplicationScope
|
||||||
|
private val externalScope: CoroutineScope
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val dao = db.instanceDao()
|
private val dao = db.instanceDao()
|
||||||
private val instanceName = accountManager.activeAccount!!.domain
|
private val instanceName
|
||||||
|
get() = accountManager.activeAccount!!.domain
|
||||||
|
|
||||||
|
/** In-memory cache for instance data, per instance domain. */
|
||||||
|
private var instanceInfoCache = ConcurrentHashMap<String, InstanceInfo>()
|
||||||
|
|
||||||
|
fun precache() {
|
||||||
|
// We are avoiding some duplicate work but we are not trying too hard.
|
||||||
|
// We might request it multiple times in parallel which is not a big problem.
|
||||||
|
// We might also get the results in random order or write them twice but it's also
|
||||||
|
// not a problem.
|
||||||
|
// We are just trying to avoid 2 things:
|
||||||
|
// - fetching it when we already have it
|
||||||
|
// - caching default value (we want to rather re-fetch if it fails)
|
||||||
|
if (instanceInfoCache[instanceName] == null) {
|
||||||
|
externalScope.launch {
|
||||||
|
fetchAndPersistInstanceInfo().onSuccess { fetched ->
|
||||||
|
instanceInfoCache[fetched.instance] = fetched.toInfoOrDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cachedInstanceInfoOrFallback: InstanceInfo
|
||||||
|
get() = instanceInfoCache[instanceName] ?: null.toInfoOrDefault()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the custom emojis of the instance.
|
* Returns the custom emojis of the instance.
|
||||||
|
@ -58,97 +94,114 @@ class InstanceInfoRepository @Inject constructor(
|
||||||
* Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available.
|
* Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available.
|
||||||
* Never throws, returns defaults of vanilla Mastodon in case of error.
|
* Never throws, returns defaults of vanilla Mastodon in case of error.
|
||||||
*/
|
*/
|
||||||
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
|
suspend fun getUpdatedInstanceInfoOrFallback(): InstanceInfo =
|
||||||
api.getInstance()
|
withContext(Dispatchers.IO) {
|
||||||
.fold(
|
fetchAndPersistInstanceInfo()
|
||||||
{ instance ->
|
.getOrElse { throwable ->
|
||||||
val instanceEntity = InstanceInfoEntity(
|
|
||||||
instance = instanceName,
|
|
||||||
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
|
||||||
maxPollOptions = instance.configuration?.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
|
||||||
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: DEFAULT_MAX_OPTION_LENGTH,
|
|
||||||
minPollDuration = instance.configuration?.polls?.minExpirationSeconds ?: DEFAULT_MIN_POLL_DURATION,
|
|
||||||
maxPollDuration = instance.configuration?.polls?.maxExpirationSeconds ?: DEFAULT_MAX_POLL_DURATION,
|
|
||||||
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
|
||||||
version = instance.version,
|
|
||||||
videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimitBytes?.toInt() ?: DEFAULT_VIDEO_SIZE_LIMIT,
|
|
||||||
imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimitBytes?.toInt() ?: DEFAULT_IMAGE_SIZE_LIMIT,
|
|
||||||
imageMatrixLimit = instance.configuration?.mediaAttachments?.imagePixelCountLimit?.toInt() ?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
|
||||||
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
|
||||||
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
|
|
||||||
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
|
|
||||||
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
|
|
||||||
)
|
|
||||||
dao.upsert(instanceEntity)
|
|
||||||
instanceEntity
|
|
||||||
},
|
|
||||||
{ throwable ->
|
|
||||||
if (throwable.isHttpNotFound()) {
|
|
||||||
getInstanceInfoV1()
|
|
||||||
} else {
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"failed to instance, falling back to cache and default values",
|
|
||||||
throwable
|
|
||||||
)
|
|
||||||
dao.getInstanceInfo(instanceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).let { instanceInfo: InstanceInfoEntity? ->
|
|
||||||
InstanceInfo(
|
|
||||||
maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
|
||||||
pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
|
||||||
pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
|
||||||
pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
|
|
||||||
pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
|
|
||||||
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
|
||||||
videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT,
|
|
||||||
imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT,
|
|
||||||
imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
|
||||||
maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
|
||||||
maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
|
|
||||||
maxFieldNameLength = instanceInfo?.maxFieldNameLength,
|
|
||||||
maxFieldValueLength = instanceInfo?.maxFieldValueLength,
|
|
||||||
version = instanceInfo?.version
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getInstanceInfoV1(): InstanceInfoEntity? = withContext(Dispatchers.IO) {
|
|
||||||
api.getInstanceV1()
|
|
||||||
.fold(
|
|
||||||
{ instance ->
|
|
||||||
val instanceEntity = InstanceInfoEntity(
|
|
||||||
instance = instanceName,
|
|
||||||
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
|
|
||||||
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
|
|
||||||
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
|
|
||||||
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
|
|
||||||
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
|
|
||||||
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
|
|
||||||
version = instance.version,
|
|
||||||
videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit,
|
|
||||||
imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit,
|
|
||||||
imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit,
|
|
||||||
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments,
|
|
||||||
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
|
|
||||||
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
|
|
||||||
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
|
|
||||||
)
|
|
||||||
dao.upsert(instanceEntity)
|
|
||||||
instanceEntity
|
|
||||||
},
|
|
||||||
{ throwable ->
|
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"failed to instance, falling back to cache and default values",
|
"failed to load instance, falling back to cache and default values",
|
||||||
throwable
|
throwable
|
||||||
)
|
)
|
||||||
dao.getInstanceInfo(instanceName)
|
dao.getInstanceInfo(instanceName)
|
||||||
}
|
}
|
||||||
)
|
}.toInfoOrDefault()
|
||||||
|
|
||||||
|
private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult<InstanceInfoEntity> =
|
||||||
|
fetchRemoteInstanceInfo()
|
||||||
|
.onSuccess { instanceInfoEntity ->
|
||||||
|
dao.upsert(instanceInfoEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchRemoteInstanceInfo(): NetworkResult<InstanceInfoEntity> {
|
||||||
|
val instance = this.instanceName
|
||||||
|
return api.getInstance()
|
||||||
|
.map { it.toEntity() }
|
||||||
|
.recover { t ->
|
||||||
|
if (t.isHttpNotFound()) {
|
||||||
|
api.getInstanceV1().map { it.toEntity(instance) }.getOrThrow()
|
||||||
|
} else {
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun InstanceInfoEntity?.toInfoOrDefault() = InstanceInfo(
|
||||||
|
maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||||
|
pollMaxOptions = this?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||||
|
pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||||
|
pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
|
||||||
|
pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
|
||||||
|
charactersReservedPerUrl = this?.charactersReservedPerUrl
|
||||||
|
?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||||
|
videoSizeLimit = this?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT,
|
||||||
|
imageSizeLimit = this?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT,
|
||||||
|
imageMatrixLimit = this?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
||||||
|
maxMediaAttachments = this?.maxMediaAttachments
|
||||||
|
?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
||||||
|
maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
|
||||||
|
maxFieldNameLength = this?.maxFieldNameLength,
|
||||||
|
maxFieldValueLength = this?.maxFieldValueLength,
|
||||||
|
version = this?.version,
|
||||||
|
translationEnabled = this?.translationEnabled
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Instance.toEntity() = InstanceInfoEntity(
|
||||||
|
instance = domain,
|
||||||
|
maximumTootCharacters = this.configuration?.statuses?.maxCharacters
|
||||||
|
?: DEFAULT_CHARACTER_LIMIT,
|
||||||
|
maxPollOptions = this.configuration?.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||||
|
maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption
|
||||||
|
?: DEFAULT_MAX_OPTION_LENGTH,
|
||||||
|
minPollDuration = this.configuration?.polls?.minExpirationSeconds
|
||||||
|
?: DEFAULT_MIN_POLL_DURATION,
|
||||||
|
maxPollDuration = this.configuration?.polls?.maxExpirationSeconds
|
||||||
|
?: DEFAULT_MAX_POLL_DURATION,
|
||||||
|
charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl
|
||||||
|
?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||||
|
version = this.version,
|
||||||
|
videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimitBytes?.toInt()
|
||||||
|
?: DEFAULT_VIDEO_SIZE_LIMIT,
|
||||||
|
imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimitBytes?.toInt()
|
||||||
|
?: DEFAULT_IMAGE_SIZE_LIMIT,
|
||||||
|
imageMatrixLimit = this.configuration?.mediaAttachments?.imagePixelCountLimit?.toInt()
|
||||||
|
?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
||||||
|
maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments
|
||||||
|
?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
||||||
|
maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||||
|
maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||||
|
maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength,
|
||||||
|
translationEnabled = this.configuration?.translation?.enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun InstanceV1.toEntity(instanceName: String) =
|
||||||
|
InstanceInfoEntity(
|
||||||
|
instance = instanceName,
|
||||||
|
maximumTootCharacters = this.configuration?.statuses?.maxCharacters
|
||||||
|
?: this.maxTootChars,
|
||||||
|
maxPollOptions = this.configuration?.polls?.maxOptions
|
||||||
|
?: this.pollConfiguration?.maxOptions,
|
||||||
|
maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption
|
||||||
|
?: this.pollConfiguration?.maxOptionChars,
|
||||||
|
minPollDuration = this.configuration?.polls?.minExpiration
|
||||||
|
?: this.pollConfiguration?.minExpiration,
|
||||||
|
maxPollDuration = this.configuration?.polls?.maxExpiration
|
||||||
|
?: this.pollConfiguration?.maxExpiration,
|
||||||
|
charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl,
|
||||||
|
version = this.version,
|
||||||
|
videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimit
|
||||||
|
?: this.uploadLimit,
|
||||||
|
imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimit
|
||||||
|
?: this.uploadLimit,
|
||||||
|
imageMatrixLimit = this.configuration?.mediaAttachments?.imageMatrixLimit,
|
||||||
|
maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments
|
||||||
|
?: this.maxMediaAttachments,
|
||||||
|
maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||||
|
maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||||
|
maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength,
|
||||||
|
translationEnabled = null,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "InstanceInfoRepo"
|
private const val TAG = "InstanceInfoRepo"
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,9 @@ import androidx.paging.PagingConfig
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
|
import at.connyduck.calladapter.networkresult.map
|
||||||
import at.connyduck.calladapter.networkresult.onFailure
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||||
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.AccountEntity
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
@ -33,6 +35,7 @@ import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||||
import com.keylesspalace.tusky.util.toViewData
|
import com.keylesspalace.tusky.util.toViewData
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
@ -41,9 +44,14 @@ import kotlinx.coroutines.launch
|
||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
mastodonApi: MastodonApi,
|
mastodonApi: MastodonApi,
|
||||||
private val timelineCases: TimelineCases,
|
private val timelineCases: TimelineCases,
|
||||||
private val accountManager: AccountManager
|
private val accountManager: AccountManager,
|
||||||
|
private val instanceInfoRepository: InstanceInfoRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
instanceInfoRepository.precache()
|
||||||
|
}
|
||||||
|
|
||||||
var currentQuery: String = ""
|
var currentQuery: String = ""
|
||||||
var currentSearchFieldContent: String? = null
|
var currentSearchFieldContent: String? = null
|
||||||
|
|
||||||
|
@ -193,6 +201,30 @@ class SearchViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun supportsTranslation(): Boolean =
|
||||||
|
instanceInfoRepository.cachedInstanceInfoOrFallback.translationEnabled == true
|
||||||
|
|
||||||
|
suspend fun translate(statusViewData: StatusViewData.Concrete): NetworkResult<Unit> {
|
||||||
|
updateStatusViewData(statusViewData.copy(translation = TranslationViewData.Loading))
|
||||||
|
return timelineCases.translate(statusViewData.actionableId)
|
||||||
|
.map { translation ->
|
||||||
|
updateStatusViewData(
|
||||||
|
statusViewData.copy(
|
||||||
|
translation = TranslationViewData.Loaded(
|
||||||
|
translation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
updateStatusViewData(statusViewData.copy(translation = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun untranslate(statusViewData: StatusViewData.Concrete) {
|
||||||
|
updateStatusViewData(statusViewData.copy(translation = null))
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) {
|
private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) {
|
||||||
val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id }
|
val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id }
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
|
|
@ -39,6 +39,7 @@ import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.keylesspalace.tusky.BaseActivity
|
import com.keylesspalace.tusky.BaseActivity
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
|
@ -56,12 +57,14 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.CardViewMode
|
import com.keylesspalace.tusky.util.CardViewMode
|
||||||
|
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
|
||||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||||
import com.keylesspalace.tusky.util.openLink
|
import com.keylesspalace.tusky.util.openLink
|
||||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||||
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -96,13 +99,24 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||||
)
|
)
|
||||||
|
|
||||||
|
binding.searchRecyclerView.setAccessibilityDelegateCompat(
|
||||||
|
ListStatusAccessibilityDelegate(binding.searchRecyclerView, this) { pos ->
|
||||||
|
if (pos in 0 until adapter.itemCount) {
|
||||||
|
adapter.peek(pos)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
binding.searchRecyclerView.addItemDecoration(
|
binding.searchRecyclerView.addItemDecoration(
|
||||||
DividerItemDecoration(
|
DividerItemDecoration(
|
||||||
binding.searchRecyclerView.context,
|
binding.searchRecyclerView.context,
|
||||||
DividerItemDecoration.VERTICAL
|
DividerItemDecoration.VERTICAL
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
|
binding.searchRecyclerView.layoutManager =
|
||||||
|
LinearLayoutManager(binding.searchRecyclerView.context)
|
||||||
return SearchStatusesAdapter(statusDisplayOptions, this)
|
return SearchStatusesAdapter(statusDisplayOptions, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,7 +145,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMore(view: View, position: Int) {
|
override fun onMore(view: View, position: Int) {
|
||||||
searchAdapter.peek(position)?.status?.let {
|
searchAdapter.peek(position)?.let {
|
||||||
more(it, view, position)
|
more(it, view, position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -159,6 +173,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Attachment.Type.UNKNOWN -> {
|
Attachment.Type.UNKNOWN -> {
|
||||||
context?.openLink(actionable.attachments[attachmentIndex].url)
|
context?.openLink(actionable.attachments[attachmentIndex].url)
|
||||||
}
|
}
|
||||||
|
@ -215,6 +230,12 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUntranslate(position: Int) {
|
||||||
|
searchAdapter.peek(position)?.let {
|
||||||
|
viewModel.untranslate(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun newInstance() = SearchStatusesFragment()
|
fun newInstance() = SearchStatusesFragment()
|
||||||
}
|
}
|
||||||
|
@ -244,7 +265,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
|
bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun more(status: Status, view: View, position: Int) {
|
private fun more(statusViewData: StatusViewData.Concrete, view: View, position: Int) {
|
||||||
|
val status = statusViewData.status
|
||||||
val id = status.actionableId
|
val id = status.actionableId
|
||||||
val accountId = status.actionableStatus.account.id
|
val accountId = status.actionableStatus.account.id
|
||||||
val accountUsername = status.actionableStatus.account.username
|
val accountUsername = status.actionableStatus.account.username
|
||||||
|
@ -266,12 +288,14 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
)
|
)
|
||||||
menu.add(0, R.id.pin, 1, textId)
|
menu.add(0, R.id.pin, 1, textId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Status.Visibility.PRIVATE -> {
|
Status.Visibility.PRIVATE -> {
|
||||||
var reblogged = status.reblogged
|
var reblogged = status.reblogged
|
||||||
if (status.reblog != null) reblogged = status.reblog.reblogged
|
if (status.reblog != null) reblogged = status.reblog.reblogged
|
||||||
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
|
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
|
||||||
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
|
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
|
||||||
}
|
}
|
||||||
|
|
||||||
Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> {
|
Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> {
|
||||||
} // Ignore
|
} // Ignore
|
||||||
}
|
}
|
||||||
|
@ -289,7 +313,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
openAsItem.title = openAsText
|
openAsItem.title = openAsText
|
||||||
}
|
}
|
||||||
|
|
||||||
val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions)
|
val mutable =
|
||||||
|
statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions)
|
||||||
val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply {
|
val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply {
|
||||||
isVisible = mutable
|
isVisible = mutable
|
||||||
}
|
}
|
||||||
|
@ -303,6 +328,14 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// translation not there for your own posts
|
||||||
|
popup.menu.findItem(R.id.status_translate)?.let { translateItem ->
|
||||||
|
translateItem.isVisible =
|
||||||
|
!status.language.equals(Locale.getDefault().language, ignoreCase = true) &&
|
||||||
|
viewModel.supportsTranslation()
|
||||||
|
translateItem.setTitle(if (statusViewData.translation != null) R.string.action_show_original else R.string.action_translate)
|
||||||
|
}
|
||||||
|
|
||||||
popup.setOnMenuItemClickListener { item ->
|
popup.setOnMenuItemClickListener { item ->
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.post_share_content -> {
|
R.id.post_share_content -> {
|
||||||
|
@ -324,6 +357,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
)
|
)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.post_share_link -> {
|
R.id.post_share_link -> {
|
||||||
val sendIntent = Intent()
|
val sendIntent = Intent()
|
||||||
sendIntent.action = Intent.ACTION_SEND
|
sendIntent.action = Intent.ACTION_SEND
|
||||||
|
@ -337,6 +371,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
)
|
)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_copy_link -> {
|
R.id.status_copy_link -> {
|
||||||
val clipboard = requireActivity().getSystemService(
|
val clipboard = requireActivity().getSystemService(
|
||||||
Context.CLIPBOARD_SERVICE
|
Context.CLIPBOARD_SERVICE
|
||||||
|
@ -344,56 +379,85 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl))
|
clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl))
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_open_as -> {
|
R.id.status_open_as -> {
|
||||||
showOpenAsDialog(statusUrl!!, item.title)
|
showOpenAsDialog(statusUrl!!, item.title)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_download_media -> {
|
R.id.status_download_media -> {
|
||||||
requestDownloadAllMedia(status)
|
requestDownloadAllMedia(status)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_mute_conversation -> {
|
R.id.status_mute_conversation -> {
|
||||||
searchAdapter.peek(position)?.let { foundStatus ->
|
searchAdapter.peek(position)?.let { foundStatus ->
|
||||||
viewModel.muteConversation(foundStatus, status.muted != true)
|
viewModel.muteConversation(foundStatus, status.muted != true)
|
||||||
}
|
}
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_mute -> {
|
R.id.status_mute -> {
|
||||||
onMute(accountId, accountUsername)
|
onMute(accountId, accountUsername)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_block -> {
|
R.id.status_block -> {
|
||||||
onBlock(accountId, accountUsername)
|
onBlock(accountId, accountUsername)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_report -> {
|
R.id.status_report -> {
|
||||||
openReportPage(accountId, accountUsername, id)
|
openReportPage(accountId, accountUsername, id)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_unreblog_private -> {
|
R.id.status_unreblog_private -> {
|
||||||
onReblog(false, position)
|
onReblog(false, position)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_reblog_private -> {
|
R.id.status_reblog_private -> {
|
||||||
onReblog(true, position)
|
onReblog(true, position)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_delete -> {
|
R.id.status_delete -> {
|
||||||
showConfirmDeleteDialog(id, position)
|
showConfirmDeleteDialog(id, position)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_delete_and_redraft -> {
|
R.id.status_delete_and_redraft -> {
|
||||||
showConfirmEditDialog(id, position, status)
|
showConfirmEditDialog(id, position, status)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_edit -> {
|
R.id.status_edit -> {
|
||||||
editStatus(id, position, status)
|
editStatus(id, position, status)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.pin -> {
|
R.id.pin -> {
|
||||||
viewModel.pinAccount(status, !status.isPinned())
|
viewModel.pinAccount(status, !status.isPinned())
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.status_translate -> {
|
||||||
|
if (statusViewData.translation != null) {
|
||||||
|
viewModel.untranslate(statusViewData)
|
||||||
|
} else {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.translate(statusViewData)
|
||||||
|
.onFailure {
|
||||||
|
Snackbar.make(
|
||||||
|
requireView(),
|
||||||
|
getString(R.string.ui_error_translate, it.message),
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,8 +36,10 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||||
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import at.connyduck.sparkbutton.helpers.Utils
|
import at.connyduck.sparkbutton.helpers.Utils
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
@ -70,6 +72,7 @@ import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import com.mikepenz.iconics.IconicsDrawable
|
import com.mikepenz.iconics.IconicsDrawable
|
||||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||||
import com.mikepenz.iconics.utils.colorInt
|
import com.mikepenz.iconics.utils.colorInt
|
||||||
|
@ -238,12 +241,14 @@ class TimelineFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is LoadState.Error -> {
|
is LoadState.Error -> {
|
||||||
binding.statusView.show()
|
binding.statusView.show()
|
||||||
binding.statusView.setup(
|
binding.statusView.setup(
|
||||||
(loadState.refresh as LoadState.Error).error
|
(loadState.refresh as LoadState.Error).error
|
||||||
) { onRefresh() }
|
) { onRefresh() }
|
||||||
}
|
}
|
||||||
|
|
||||||
is LoadState.Loading -> {
|
is LoadState.Loading -> {
|
||||||
binding.progressBar.show()
|
binding.progressBar.show()
|
||||||
}
|
}
|
||||||
|
@ -306,6 +311,7 @@ class TimelineFragment :
|
||||||
is PreferenceChangedEvent -> {
|
is PreferenceChangedEvent -> {
|
||||||
onPreferenceChanged(event.preferenceKey)
|
onPreferenceChanged(event.preferenceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
is StatusComposedEvent -> {
|
is StatusComposedEvent -> {
|
||||||
val status = event.status
|
val status = event.status
|
||||||
handleStatusComposeEvent(status)
|
handleStatusComposeEvent(status)
|
||||||
|
@ -348,6 +354,7 @@ class TimelineFragment :
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -415,6 +422,17 @@ class TimelineFragment :
|
||||||
adapter.refresh()
|
adapter.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val onMoreTranslate =
|
||||||
|
{ translate: Boolean, position: Int ->
|
||||||
|
if (translate) {
|
||||||
|
onTranslate(position)
|
||||||
|
} else {
|
||||||
|
onUntranslate(
|
||||||
|
position
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onReply(position: Int) {
|
override fun onReply(position: Int) {
|
||||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
super.reply(status.status)
|
super.reply(status.status)
|
||||||
|
@ -425,6 +443,25 @@ class TimelineFragment :
|
||||||
viewModel.reblog(reblog, status)
|
viewModel.reblog(reblog, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onTranslate(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.translate(status)
|
||||||
|
.onFailure {
|
||||||
|
Snackbar.make(
|
||||||
|
requireView(),
|
||||||
|
getString(R.string.ui_error_translate, it.message),
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUntranslate(position: Int) {
|
||||||
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
|
viewModel.untranslate(status)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
viewModel.favorite(favourite, status)
|
viewModel.favorite(favourite, status)
|
||||||
|
@ -447,7 +484,12 @@ class TimelineFragment :
|
||||||
|
|
||||||
override fun onMore(view: View, position: Int) {
|
override fun onMore(view: View, position: Int) {
|
||||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||||
super.more(status.status, view, position)
|
super.more(
|
||||||
|
status.status,
|
||||||
|
view,
|
||||||
|
position,
|
||||||
|
(status.translation as? TranslationViewData.Loaded)?.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenReblog(position: Int) {
|
override fun onOpenReblog(position: Int) {
|
||||||
|
@ -480,7 +522,8 @@ class TimelineFragment :
|
||||||
override fun onLoadMore(position: Int) {
|
override fun onLoadMore(position: Int) {
|
||||||
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
|
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
|
||||||
loadMorePosition = position
|
loadMorePosition = position
|
||||||
statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null
|
statusIdBelowLoadMore =
|
||||||
|
if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null
|
||||||
viewModel.loadMore(placeholder.id)
|
viewModel.loadMore(placeholder.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -533,6 +576,7 @@ class TimelineFragment :
|
||||||
PrefKeys.FAB_HIDE -> {
|
PrefKeys.FAB_HIDE -> {
|
||||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||||
|
@ -541,6 +585,7 @@ class TimelineFragment :
|
||||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PrefKeys.READING_ORDER -> {
|
PrefKeys.READING_ORDER -> {
|
||||||
readingOrder = ReadingOrder.from(
|
readingOrder = ReadingOrder.from(
|
||||||
sharedPreferences.getString(PrefKeys.READING_ORDER, null)
|
sharedPreferences.getString(PrefKeys.READING_ORDER, null)
|
||||||
|
@ -555,10 +600,12 @@ class TimelineFragment :
|
||||||
TimelineViewModel.Kind.PUBLIC_FEDERATED,
|
TimelineViewModel.Kind.PUBLIC_FEDERATED,
|
||||||
TimelineViewModel.Kind.PUBLIC_LOCAL,
|
TimelineViewModel.Kind.PUBLIC_LOCAL,
|
||||||
TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh()
|
TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh()
|
||||||
|
|
||||||
TimelineViewModel.Kind.USER,
|
TimelineViewModel.Kind.USER,
|
||||||
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
|
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
|
||||||
adapter.refresh()
|
adapter.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
TimelineViewModel.Kind.TAG,
|
TimelineViewModel.Kind.TAG,
|
||||||
TimelineViewModel.Kind.FAVOURITES,
|
TimelineViewModel.Kind.FAVOURITES,
|
||||||
TimelineViewModel.Kind.LIST,
|
TimelineViewModel.Kind.LIST,
|
||||||
|
@ -583,13 +630,14 @@ class TimelineFragment :
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
(binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position ->
|
(binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()
|
||||||
if (position != RecyclerView.NO_POSITION) {
|
?.let { position ->
|
||||||
adapter.snapshot().getOrNull(position)?.id?.let { statusId ->
|
if (position != RecyclerView.NO_POSITION) {
|
||||||
viewModel.saveReadingPosition(statusId)
|
adapter.snapshot().getOrNull(position)?.id?.let { statusId ->
|
||||||
|
viewModel.saveReadingPosition(statusId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
private const val TAG = "TimelineTypeMappers"
|
private const val TAG = "TimelineTypeMappers"
|
||||||
|
@ -155,7 +156,7 @@ fun Status.toEntity(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData {
|
fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData {
|
||||||
if (this.account == null) {
|
if (this.account == null) {
|
||||||
Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})")
|
Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})")
|
||||||
return StatusViewData.Placeholder(this.status.serverId, this.status.expanded)
|
return StatusViewData.Placeholder(this.status.serverId, this.status.expanded)
|
||||||
|
@ -199,7 +200,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
|
||||||
card = card,
|
card = card,
|
||||||
repliesCount = status.repliesCount,
|
repliesCount = status.repliesCount,
|
||||||
language = status.language,
|
language = status.language,
|
||||||
filtered = status.filtered
|
filtered = status.filtered,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val status = if (reblog != null) {
|
val status = if (reblog != null) {
|
||||||
|
@ -244,7 +245,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
|
||||||
inReplyToId = status.inReplyToId,
|
inReplyToId = status.inReplyToId,
|
||||||
inReplyToAccountId = status.inReplyToAccountId,
|
inReplyToAccountId = status.inReplyToAccountId,
|
||||||
reblog = null,
|
reblog = null,
|
||||||
content = status.content.orEmpty(),
|
content = translation?.data?.content ?: status.content.orEmpty(),
|
||||||
createdAt = Date(status.createdAt),
|
createdAt = Date(status.createdAt),
|
||||||
editedAt = status.editedAt?.let { Date(it) },
|
editedAt = status.editedAt?.let { Date(it) },
|
||||||
emojis = emojis,
|
emojis = emojis,
|
||||||
|
@ -274,6 +275,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
|
||||||
isExpanded = this.status.expanded,
|
isExpanded = this.status.expanded,
|
||||||
isShowingContent = this.status.contentShowing,
|
isShowingContent = this.status.contentShowing,
|
||||||
isCollapsed = this.status.contentCollapsed,
|
isCollapsed = this.status.contentCollapsed,
|
||||||
isDetailed = isDetailed
|
isDetailed = isDetailed,
|
||||||
|
translation = translation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,9 @@ import androidx.paging.cachedIn
|
||||||
import androidx.paging.filter
|
import androidx.paging.filter
|
||||||
import androidx.paging.map
|
import androidx.paging.map
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
|
import at.connyduck.calladapter.networkresult.map
|
||||||
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST
|
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST
|
||||||
|
@ -45,11 +48,13 @@ import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||||
import com.keylesspalace.tusky.util.EmptyPagingSource
|
import com.keylesspalace.tusky.util.EmptyPagingSource
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.asExecutor
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
@ -76,6 +81,9 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
|
|
||||||
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
|
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
|
||||||
|
|
||||||
|
/** Map from status id to translation. */
|
||||||
|
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
override val statuses = Pager(
|
override val statuses = Pager(
|
||||||
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
||||||
|
@ -91,15 +99,24 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
).flow
|
).flow
|
||||||
.map { pagingData ->
|
// Apply cachedIn() early to be able to combine with translation flow.
|
||||||
|
// This will not cache ViewData's but practically we don't need this.
|
||||||
|
// If you notice that this flow is used in more than once place consider
|
||||||
|
// adding another cachedIn() for the overall result.
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
.combine(translations) { pagingData, translations ->
|
||||||
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
|
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
|
||||||
timelineStatus.toViewData(gson)
|
val translation = translations[timelineStatus.status.serverId]
|
||||||
|
timelineStatus.toViewData(
|
||||||
|
gson,
|
||||||
|
isDetailed = false,
|
||||||
|
translation = translation
|
||||||
|
)
|
||||||
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
|
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
|
||||||
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
|
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
.cachedIn(viewModelScope)
|
|
||||||
|
|
||||||
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
|
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
|
||||||
// handled by CacheUpdater
|
// handled by CacheUpdater
|
||||||
|
@ -276,8 +293,23 @@ class CachedTimelineViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> {
|
||||||
|
translations.value = translations.value + (status.id to TranslationViewData.Loading)
|
||||||
|
return timelineCases.translate(status.actionableId)
|
||||||
|
.map { translation ->
|
||||||
|
translations.value =
|
||||||
|
translations.value + (status.id to TranslationViewData.Loaded(translation))
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
translations.value = translations.value - status.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun untranslate(status: StatusViewData.Concrete) {
|
||||||
|
translations.value = translations.value - status.id
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "CachedTimelineViewModel"
|
private const val TAG = "CachedTimelineViewModel"
|
||||||
private const val MAX_STATUSES_IN_CACHE = 1000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,9 @@ import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import androidx.paging.filter
|
import androidx.paging.filter
|
||||||
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
|
import at.connyduck.calladapter.networkresult.map
|
||||||
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
|
@ -37,6 +40,7 @@ import com.keylesspalace.tusky.util.isLessThan
|
||||||
import com.keylesspalace.tusky.util.isLessThanOrEqual
|
import com.keylesspalace.tusky.util.isLessThanOrEqual
|
||||||
import com.keylesspalace.tusky.util.toViewData
|
import com.keylesspalace.tusky.util.toViewData
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -145,7 +149,8 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
try {
|
try {
|
||||||
val placeholderIndex =
|
val placeholderIndex =
|
||||||
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
|
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
|
||||||
statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true)
|
statusData[placeholderIndex] =
|
||||||
|
StatusViewData.Placeholder(placeholderId, isLoading = true)
|
||||||
|
|
||||||
val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id
|
val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id
|
||||||
|
|
||||||
|
@ -178,7 +183,9 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
val overlappedFrom = statusData.indexOfFirst {
|
val overlappedFrom = statusData.indexOfFirst {
|
||||||
it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false
|
it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false
|
||||||
}
|
}
|
||||||
val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false }
|
val overlappedTo = statusData.indexOfFirst {
|
||||||
|
it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
if (overlappedFrom < overlappedTo) {
|
if (overlappedFrom < overlappedTo) {
|
||||||
data.mapIndexed { i, status ->
|
data.mapIndexed { i, status ->
|
||||||
|
@ -198,12 +205,18 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
|
|
||||||
statusData.removeAll { status ->
|
statusData.removeAll { status ->
|
||||||
when (status) {
|
when (status) {
|
||||||
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
|
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(
|
||||||
is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
|
firstId
|
||||||
|
)
|
||||||
|
|
||||||
|
is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(
|
||||||
|
firstId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false)
|
data[data.size - 1] =
|
||||||
|
StatusViewData.Placeholder(statuses.last().id, isLoading = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -258,6 +271,21 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
currentSource?.invalidate()
|
currentSource?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> {
|
||||||
|
status.copy(translation = TranslationViewData.Loading).update()
|
||||||
|
return timelineCases.translate(status.actionableId)
|
||||||
|
.map { translation ->
|
||||||
|
status.copy(translation = TranslationViewData.Loaded(translation)).update()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
status.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun untranslate(status: StatusViewData.Concrete) {
|
||||||
|
status.copy(translation = null).update()
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(IOException::class, HttpException::class)
|
@Throws(IOException::class, HttpException::class)
|
||||||
suspend fun fetchStatusesForKind(
|
suspend fun fetchStatusesForKind(
|
||||||
fromId: String?,
|
fromId: String?,
|
||||||
|
@ -273,6 +301,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
val additionalHashtags = tags.subList(1, tags.size)
|
val additionalHashtags = tags.subList(1, tags.size)
|
||||||
api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit)
|
api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
Kind.USER -> api.accountStatuses(
|
Kind.USER -> api.accountStatuses(
|
||||||
id!!,
|
id!!,
|
||||||
fromId,
|
fromId,
|
||||||
|
@ -282,6 +311,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
onlyMedia = null,
|
onlyMedia = null,
|
||||||
pinned = null
|
pinned = null
|
||||||
)
|
)
|
||||||
|
|
||||||
Kind.USER_PINNED -> api.accountStatuses(
|
Kind.USER_PINNED -> api.accountStatuses(
|
||||||
id!!,
|
id!!,
|
||||||
fromId,
|
fromId,
|
||||||
|
@ -291,6 +321,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
onlyMedia = null,
|
onlyMedia = null,
|
||||||
pinned = true
|
pinned = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Kind.USER_WITH_REPLIES -> api.accountStatuses(
|
Kind.USER_WITH_REPLIES -> api.accountStatuses(
|
||||||
id!!,
|
id!!,
|
||||||
fromId,
|
fromId,
|
||||||
|
@ -300,6 +331,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
onlyMedia = null,
|
onlyMedia = null,
|
||||||
pinned = null
|
pinned = null
|
||||||
)
|
)
|
||||||
|
|
||||||
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
|
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
|
||||||
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
|
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
|
||||||
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
|
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
|
||||||
|
@ -308,7 +340,8 @@ class NetworkTimelineViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun StatusViewData.Concrete.update() {
|
private fun StatusViewData.Concrete.update() {
|
||||||
val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id }
|
val position =
|
||||||
|
statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id }
|
||||||
statusData[position] = this
|
statusData[position] = this
|
||||||
currentSource?.invalidate()
|
currentSource?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import at.connyduck.calladapter.networkresult.getOrElse
|
import at.connyduck.calladapter.networkresult.getOrElse
|
||||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||||
|
@ -52,7 +53,7 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
abstract class TimelineViewModel(
|
abstract class TimelineViewModel(
|
||||||
private val timelineCases: TimelineCases,
|
protected val timelineCases: TimelineCases,
|
||||||
private val api: MastodonApi,
|
private val api: MastodonApi,
|
||||||
private val eventHub: EventHub,
|
private val eventHub: EventHub,
|
||||||
protected val accountManager: AccountManager,
|
protected val accountManager: AccountManager,
|
||||||
|
@ -312,6 +313,9 @@ abstract class TimelineViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit>
|
||||||
|
abstract fun untranslate(status: StatusViewData.Concrete)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "TimelineVM"
|
private const val TAG = "TimelineVM"
|
||||||
internal const val LOAD_AT_ONCE = 30
|
internal const val LOAD_AT_ONCE = 30
|
||||||
|
|
|
@ -35,6 +35,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||||
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||||
|
@ -56,6 +57,7 @@ import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||||
import com.keylesspalace.tusky.util.viewBinding
|
import com.keylesspalace.tusky.util.viewBinding
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.awaitCancellation
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
@ -166,6 +168,7 @@ class ViewThreadFragment :
|
||||||
initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
|
initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
|
||||||
initialProgressBar.start()
|
initialProgressBar.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
is ThreadUiState.LoadingThread -> {
|
is ThreadUiState.LoadingThread -> {
|
||||||
if (uiState.statusViewDatum == null) {
|
if (uiState.statusViewDatum == null) {
|
||||||
// no detailed statuses available, e.g. because author is blocked
|
// no detailed statuses available, e.g. because author is blocked
|
||||||
|
@ -189,6 +192,7 @@ class ViewThreadFragment :
|
||||||
binding.recyclerView.show()
|
binding.recyclerView.show()
|
||||||
binding.statusView.hide()
|
binding.statusView.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
is ThreadUiState.Error -> {
|
is ThreadUiState.Error -> {
|
||||||
Log.w(TAG, "failed to load status", uiState.throwable)
|
Log.w(TAG, "failed to load status", uiState.throwable)
|
||||||
initialProgressBar.cancel()
|
initialProgressBar.cancel()
|
||||||
|
@ -204,6 +208,7 @@ class ViewThreadFragment :
|
||||||
uiState.throwable
|
uiState.throwable
|
||||||
) { viewModel.retry(thisThreadsStatusId) }
|
) { viewModel.retry(thisThreadsStatusId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is ThreadUiState.Success -> {
|
is ThreadUiState.Success -> {
|
||||||
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
|
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
|
||||||
// no detailed statuses available, e.g. because author is blocked
|
// no detailed statuses available, e.g. because author is blocked
|
||||||
|
@ -231,6 +236,7 @@ class ViewThreadFragment :
|
||||||
binding.recyclerView.show()
|
binding.recyclerView.show()
|
||||||
binding.statusView.hide()
|
binding.statusView.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
is ThreadUiState.Refreshing -> {
|
is ThreadUiState.Refreshing -> {
|
||||||
threadProgressBar.cancel()
|
threadProgressBar.cancel()
|
||||||
}
|
}
|
||||||
|
@ -270,14 +276,17 @@ class ViewThreadFragment :
|
||||||
viewModel.toggleRevealButton()
|
viewModel.toggleRevealButton()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_open_in_web -> {
|
R.id.action_open_in_web -> {
|
||||||
context?.openLink(requireArguments().getString(URL_EXTRA)!!)
|
context?.openLink(requireArguments().getString(URL_EXTRA)!!)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_refresh -> {
|
R.id.action_refresh -> {
|
||||||
onRefresh()
|
onRefresh()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -323,6 +332,36 @@ class ViewThreadFragment :
|
||||||
viewModel.reblog(reblog, status)
|
viewModel.reblog(reblog, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit) =
|
||||||
|
{ translate: Boolean, position: Int ->
|
||||||
|
if (translate) {
|
||||||
|
onTranslate(position)
|
||||||
|
} else {
|
||||||
|
onUntranslate(
|
||||||
|
position
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTranslate(position: Int) {
|
||||||
|
val status = adapter.currentList[position]
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.translate(status)
|
||||||
|
.onFailure {
|
||||||
|
Snackbar.make(
|
||||||
|
requireView(),
|
||||||
|
getString(R.string.ui_error_translate, it.message),
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUntranslate(position: Int) {
|
||||||
|
val status = adapter.currentList[position]
|
||||||
|
viewModel.untranslate(status)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||||
val status = adapter.currentList[position]
|
val status = adapter.currentList[position]
|
||||||
viewModel.favorite(favourite, status)
|
viewModel.favorite(favourite, status)
|
||||||
|
@ -334,7 +373,13 @@ class ViewThreadFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMore(view: View, position: Int) {
|
override fun onMore(view: View, position: Int) {
|
||||||
super.more(adapter.currentList[position].status, view, position)
|
val viewData = adapter.currentList[position]
|
||||||
|
super.more(
|
||||||
|
viewData.status,
|
||||||
|
view,
|
||||||
|
position,
|
||||||
|
(viewData.translation as? TranslationViewData.Loaded)?.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||||
|
|
|
@ -18,9 +18,12 @@ package com.keylesspalace.tusky.components.viewthread
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||||
import at.connyduck.calladapter.networkresult.fold
|
import at.connyduck.calladapter.networkresult.fold
|
||||||
import at.connyduck.calladapter.networkresult.getOrElse
|
import at.connyduck.calladapter.networkresult.getOrElse
|
||||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||||
|
import at.connyduck.calladapter.networkresult.map
|
||||||
|
import at.connyduck.calladapter.networkresult.onFailure
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||||
import com.keylesspalace.tusky.appstore.EventHub
|
import com.keylesspalace.tusky.appstore.EventHub
|
||||||
|
@ -40,6 +43,7 @@ import com.keylesspalace.tusky.usecase.TimelineCases
|
||||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||||
import com.keylesspalace.tusky.util.toViewData
|
import com.keylesspalace.tusky.util.toViewData
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
@ -110,7 +114,7 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
Log.d(TAG, "Loaded status from local timeline")
|
Log.d(TAG, "Loaded status from local timeline")
|
||||||
val viewData = timelineStatus.toViewData(
|
val viewData = timelineStatus.toViewData(
|
||||||
gson,
|
gson,
|
||||||
isDetailed = true
|
isDetailed = true,
|
||||||
) as StatusViewData.Concrete
|
) as StatusViewData.Concrete
|
||||||
|
|
||||||
// Return the correct status, depending on which one matched. If you do not do
|
// Return the correct status, depending on which one matched. If you do not do
|
||||||
|
@ -154,8 +158,10 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
val contextResult = contextCall.await()
|
val contextResult = contextCall.await()
|
||||||
|
|
||||||
contextResult.fold({ statusContext ->
|
contextResult.fold({ statusContext ->
|
||||||
val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter()
|
val ancestors =
|
||||||
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter()
|
statusContext.ancestors.map { status -> status.toViewData() }.filter()
|
||||||
|
val descendants =
|
||||||
|
statusContext.descendants.map { status -> status.toViewData() }.filter()
|
||||||
val statuses = ancestors + detailedStatus + descendants
|
val statuses = ancestors + detailedStatus + descendants
|
||||||
|
|
||||||
_uiState.value = ThreadUiState.Success(
|
_uiState.value = ThreadUiState.Success(
|
||||||
|
@ -189,6 +195,7 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
is ThreadUiState.Success -> uiState.statusViewData.find { status ->
|
is ThreadUiState.Success -> uiState.statusViewData.find { status ->
|
||||||
status.isDetailed
|
status.isDetailed
|
||||||
}
|
}
|
||||||
|
|
||||||
is ThreadUiState.LoadingThread -> uiState.statusViewDatum
|
is ThreadUiState.LoadingThread -> uiState.statusViewDatum
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
@ -281,13 +288,37 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> {
|
||||||
|
updateStatusViewData(status.id) { viewData ->
|
||||||
|
viewData.copy(translation = TranslationViewData.Loading)
|
||||||
|
}
|
||||||
|
return timelineCases.translate(status.actionableId)
|
||||||
|
.map { translation ->
|
||||||
|
updateStatusViewData(status.id) { viewData ->
|
||||||
|
viewData.copy(translation = TranslationViewData.Loaded(translation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
updateStatusViewData(status.id) { viewData ->
|
||||||
|
viewData.copy(translation = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun untranslate(status: StatusViewData.Concrete) {
|
||||||
|
updateStatusViewData(status.id) { viewData ->
|
||||||
|
viewData.copy(translation = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleStatusChangedEvent(status: Status) {
|
private fun handleStatusChangedEvent(status: Status) {
|
||||||
updateStatusViewData(status.id) { viewData ->
|
updateStatusViewData(status.id) { viewData ->
|
||||||
status.toViewData(
|
status.toViewData(
|
||||||
isShowingContent = viewData.isShowingContent,
|
isShowingContent = viewData.isShowingContent,
|
||||||
isExpanded = viewData.isExpanded,
|
isExpanded = viewData.isExpanded,
|
||||||
isCollapsed = viewData.isCollapsed,
|
isCollapsed = viewData.isCollapsed,
|
||||||
isDetailed = viewData.isDetailed
|
isDetailed = viewData.isDetailed,
|
||||||
|
translation = viewData.translation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -307,7 +338,8 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
updateSuccess { uiState ->
|
updateSuccess { uiState ->
|
||||||
val statuses = uiState.statusViewData
|
val statuses = uiState.statusViewData
|
||||||
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
|
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
|
||||||
val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
|
val repliedIndex =
|
||||||
|
statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
|
||||||
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
|
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
|
||||||
// there is a new reply to the detailed status or below -> display it
|
// there is a new reply to the detailed status or below -> display it
|
||||||
val newStatuses = statuses.subList(0, repliedIndex + 1) +
|
val newStatuses = statuses.subList(0, repliedIndex + 1) +
|
||||||
|
@ -339,12 +371,14 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
},
|
},
|
||||||
revealButton = RevealButtonState.REVEAL
|
revealButton = RevealButtonState.REVEAL
|
||||||
)
|
)
|
||||||
|
|
||||||
RevealButtonState.REVEAL -> uiState.copy(
|
RevealButtonState.REVEAL -> uiState.copy(
|
||||||
statusViewData = uiState.statusViewData.map { viewData ->
|
statusViewData = uiState.statusViewData.map { viewData ->
|
||||||
viewData.copy(isExpanded = true)
|
viewData.copy(isExpanded = true)
|
||||||
},
|
},
|
||||||
revealButton = RevealButtonState.HIDE
|
revealButton = RevealButtonState.HIDE
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> uiState
|
else -> uiState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -441,7 +475,8 @@ class ViewThreadViewModel @Inject constructor(
|
||||||
it.id == this.id
|
it.id == this.id
|
||||||
}
|
}
|
||||||
return toViewData(
|
return toViewData(
|
||||||
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
|
isShowingContent = oldStatus?.isShowingContent
|
||||||
|
?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
|
||||||
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
|
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
|
||||||
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
|
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
|
||||||
isDetailed = oldStatus?.isDetailed ?: isDetailed
|
isDetailed = oldStatus?.isDetailed ?: isDetailed
|
||||||
|
|
|
@ -44,13 +44,14 @@ import java.io.File;
|
||||||
},
|
},
|
||||||
// Note: Starting with version 54, database versions in Tusky are always even.
|
// Note: Starting with version 54, database versions in Tusky are always even.
|
||||||
// This is to reserve odd version numbers for use by forks.
|
// This is to reserve odd version numbers for use by forks.
|
||||||
version = 56,
|
version = 58,
|
||||||
autoMigrations = {
|
autoMigrations = {
|
||||||
@AutoMigration(from = 48, to = 49),
|
@AutoMigration(from = 48, to = 49),
|
||||||
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
|
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
|
||||||
@AutoMigration(from = 50, to = 51),
|
@AutoMigration(from = 50, to = 51),
|
||||||
@AutoMigration(from = 51, to = 52),
|
@AutoMigration(from = 51, to = 52),
|
||||||
@AutoMigration(from = 53, to = 54) // hasDirectMessageBadge in AccountEntity
|
@AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity
|
||||||
|
@AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
public abstract class AppDatabase extends RoomDatabase {
|
public abstract class AppDatabase extends RoomDatabase {
|
||||||
|
|
|
@ -38,7 +38,8 @@ data class InstanceEntity(
|
||||||
val maxMediaAttachments: Int?,
|
val maxMediaAttachments: Int?,
|
||||||
val maxFields: Int?,
|
val maxFields: Int?,
|
||||||
val maxFieldNameLength: Int?,
|
val maxFieldNameLength: Int?,
|
||||||
val maxFieldValueLength: Int?
|
val maxFieldValueLength: Int?,
|
||||||
|
val translationEnabled: Boolean?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
@ -62,5 +63,6 @@ data class InstanceInfoEntity(
|
||||||
val maxMediaAttachments: Int?,
|
val maxMediaAttachments: Int?,
|
||||||
val maxFields: Int?,
|
val maxFields: Int?,
|
||||||
val maxFieldNameLength: Int?,
|
val maxFieldNameLength: Int?,
|
||||||
val maxFieldValueLength: Int?
|
val maxFieldValueLength: Int?,
|
||||||
|
val translationEnabled: Boolean?,
|
||||||
)
|
)
|
||||||
|
|
|
@ -49,8 +49,11 @@ data class Status(
|
||||||
val pinned: Boolean?,
|
val pinned: Boolean?,
|
||||||
val muted: Boolean?,
|
val muted: Boolean?,
|
||||||
val poll: Poll?,
|
val poll: Poll?,
|
||||||
|
/** Preview card for links included within status content. */
|
||||||
val card: Card?,
|
val card: Card?,
|
||||||
|
/** ISO 639 language code for this status. */
|
||||||
val language: String?,
|
val language: String?,
|
||||||
|
/** If the current token has an authorized user: The filter and keywords that matched this status. */
|
||||||
val filtered: List<FilterResult>?
|
val filtered: List<FilterResult>?
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class MediaTranslation(
|
||||||
|
val id: String,
|
||||||
|
val description: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the result of machine translating some status content.
|
||||||
|
*
|
||||||
|
* See [doc](https://docs.joinmastodon.org/entities/Translation/).
|
||||||
|
*/
|
||||||
|
data class Translation(
|
||||||
|
val content: String,
|
||||||
|
@SerializedName("spoiler_warning")
|
||||||
|
val spoilerWarning: String?,
|
||||||
|
val poll: List<String>?,
|
||||||
|
@SerializedName("media_attachments")
|
||||||
|
val mediaAttachments: List<MediaTranslation>,
|
||||||
|
@SerializedName("detected_source_language")
|
||||||
|
val detectedSourceLanguage: String,
|
||||||
|
val provider: String,
|
||||||
|
)
|
|
@ -109,6 +109,7 @@ import at.connyduck.sparkbutton.helpers.Utils;
|
||||||
import kotlin.Unit;
|
import kotlin.Unit;
|
||||||
import kotlin.collections.CollectionsKt;
|
import kotlin.collections.CollectionsKt;
|
||||||
import kotlin.jvm.functions.Function1;
|
import kotlin.jvm.functions.Function1;
|
||||||
|
import kotlin.jvm.functions.Function2;
|
||||||
import kotlinx.coroutines.Job;
|
import kotlinx.coroutines.Job;
|
||||||
|
|
||||||
public class NotificationsFragment extends SFragment implements
|
public class NotificationsFragment extends SFragment implements
|
||||||
|
@ -408,6 +409,12 @@ public class NotificationsFragment extends SFragment implements
|
||||||
sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1);
|
sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
protected Function2<Boolean, Integer, Unit> getOnMoreTranslate() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onReply(int position) {
|
public void onReply(int position) {
|
||||||
super.reply(notifications.get(position).asRight().getStatus());
|
super.reply(notifications.get(position).asRight().getStatus());
|
||||||
|
@ -490,7 +497,7 @@ public class NotificationsFragment extends SFragment implements
|
||||||
@Override
|
@Override
|
||||||
public void onMore(@NonNull View view, int position) {
|
public void onMore(@NonNull View view, int position) {
|
||||||
Notification notification = notifications.get(position).asRight();
|
Notification notification = notifications.get(position).asRight();
|
||||||
super.more(notification.getStatus(), view, position);
|
super.more(notification.getStatus(), view, position, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -525,10 +532,6 @@ public class NotificationsFragment extends SFragment implements
|
||||||
updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing));
|
updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setPinForStatus(String statusId, boolean pinned) {
|
|
||||||
updateStatus(statusId, status -> status.copyWithPinned(pinned));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onLoadMore(int position) {
|
public void onLoadMore(int position) {
|
||||||
// Check bounds before accessing list,
|
// Check bounds before accessing list,
|
||||||
|
@ -555,6 +558,11 @@ public class NotificationsFragment extends SFragment implements
|
||||||
updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed));
|
updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onUntranslate(int position) {
|
||||||
|
// not needed
|
||||||
|
}
|
||||||
|
|
||||||
private void updateStatus(String statusId, Function<Status, Status> mapper) {
|
private void updateStatus(String statusId, Function<Status, Status> mapper) {
|
||||||
int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() &&
|
int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() &&
|
||||||
s.asRight().getStatus() != null &&
|
s.asRight().getStatus() != null &&
|
||||||
|
|
|
@ -46,12 +46,14 @@ import com.keylesspalace.tusky.ViewMediaActivity.Companion.newIntent
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.startIntent
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.startIntent
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||||
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||||
import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent
|
import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent
|
||||||
import com.keylesspalace.tusky.db.AccountEntity
|
import com.keylesspalace.tusky.db.AccountEntity
|
||||||
import com.keylesspalace.tusky.db.AccountManager
|
import com.keylesspalace.tusky.db.AccountManager
|
||||||
import com.keylesspalace.tusky.di.Injectable
|
import com.keylesspalace.tusky.di.Injectable
|
||||||
import com.keylesspalace.tusky.entity.Attachment
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.entity.Translation
|
||||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||||
|
@ -60,6 +62,7 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||||
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
||||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@ -72,6 +75,10 @@ import kotlinx.coroutines.launch
|
||||||
abstract class SFragment : Fragment(), Injectable {
|
abstract class SFragment : Fragment(), Injectable {
|
||||||
protected abstract fun removeItem(position: Int)
|
protected abstract fun removeItem(position: Int)
|
||||||
protected abstract fun onReblog(reblog: Boolean, position: Int)
|
protected abstract fun onReblog(reblog: Boolean, position: Int)
|
||||||
|
|
||||||
|
/** `null` if translation is not supported on this screen */
|
||||||
|
protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)?
|
||||||
|
|
||||||
private lateinit var bottomSheetActivity: BottomSheetActivity
|
private lateinit var bottomSheetActivity: BottomSheetActivity
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
@ -83,6 +90,9 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var timelineCases: TimelineCases
|
lateinit var timelineCases: TimelineCases
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var instanceInfoRepository: InstanceInfoRepository
|
||||||
|
|
||||||
override fun startActivity(intent: Intent) {
|
override fun startActivity(intent: Intent) {
|
||||||
requireActivity().startActivityWithSlideInAnimation(intent)
|
requireActivity().startActivityWithSlideInAnimation(intent)
|
||||||
}
|
}
|
||||||
|
@ -96,6 +106,13 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
// make sure we have instance info for when we'll need it
|
||||||
|
instanceInfoRepository.precache()
|
||||||
|
}
|
||||||
|
|
||||||
protected fun openReblog(status: Status?) {
|
protected fun openReblog(status: Status?) {
|
||||||
if (status == null) return
|
if (status == null) return
|
||||||
bottomSheetActivity.viewAccount(status.account.id)
|
bottomSheetActivity.viewAccount(status.account.id)
|
||||||
|
@ -140,7 +157,7 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
requireActivity().startActivity(intent)
|
requireActivity().startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun more(status: Status, view: View, position: Int) {
|
protected fun more(status: Status, view: View, position: Int, translation: Translation?) {
|
||||||
val id = status.actionableId
|
val id = status.actionableId
|
||||||
val accountId = status.actionableStatus.account.id
|
val accountId = status.actionableStatus.account.id
|
||||||
val accountUsername = status.actionableStatus.account.username
|
val accountUsername = status.actionableStatus.account.username
|
||||||
|
@ -167,16 +184,19 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Status.Visibility.PRIVATE -> {
|
Status.Visibility.PRIVATE -> {
|
||||||
val reblogged = status.reblog?.reblogged ?: status.reblogged
|
val reblogged = status.reblog?.reblogged ?: status.reblogged
|
||||||
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
|
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
|
||||||
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
|
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
popup.inflate(R.menu.status_more)
|
popup.inflate(R.menu.status_more)
|
||||||
popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty()
|
popup.menu.findItem(R.id.status_download_media).isVisible =
|
||||||
|
status.attachments.isNotEmpty()
|
||||||
}
|
}
|
||||||
val menu = popup.menu
|
val menu = popup.menu
|
||||||
val openAsItem = menu.findItem(R.id.status_open_as)
|
val openAsItem = menu.findItem(R.id.status_open_as)
|
||||||
|
@ -187,7 +207,8 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
openAsItem.title = openAsText
|
openAsItem.title = openAsText
|
||||||
}
|
}
|
||||||
val muteConversationItem = menu.findItem(R.id.status_mute_conversation)
|
val muteConversationItem = menu.findItem(R.id.status_mute_conversation)
|
||||||
val mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions)
|
val mutable =
|
||||||
|
statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions)
|
||||||
muteConversationItem.isVisible = mutable
|
muteConversationItem.isVisible = mutable
|
||||||
if (mutable) {
|
if (mutable) {
|
||||||
muteConversationItem.setTitle(
|
muteConversationItem.setTitle(
|
||||||
|
@ -198,6 +219,15 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// translation not there for your own posts
|
||||||
|
menu.findItem(R.id.status_translate)?.let { translateItem ->
|
||||||
|
translateItem.isVisible = onMoreTranslate != null &&
|
||||||
|
!status.language.equals(Locale.getDefault().language, ignoreCase = true) &&
|
||||||
|
instanceInfoRepository.cachedInstanceInfoOrFallback.translationEnabled == true
|
||||||
|
translateItem.setTitle(if (translation != null) R.string.action_show_original else R.string.action_translate)
|
||||||
|
}
|
||||||
|
|
||||||
popup.setOnMenuItemClickListener { item: MenuItem ->
|
popup.setOnMenuItemClickListener { item: MenuItem ->
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.post_share_content -> {
|
R.id.post_share_content -> {
|
||||||
|
@ -219,6 +249,7 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
)
|
)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.post_share_link -> {
|
R.id.post_share_link -> {
|
||||||
val sendIntent = Intent().apply {
|
val sendIntent = Intent().apply {
|
||||||
action = Intent.ACTION_SEND
|
action = Intent.ACTION_SEND
|
||||||
|
@ -233,6 +264,7 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
)
|
)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_copy_link -> {
|
R.id.status_copy_link -> {
|
||||||
(
|
(
|
||||||
requireActivity().getSystemService(
|
requireActivity().getSystemService(
|
||||||
|
@ -243,62 +275,80 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
}
|
}
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_open_as -> {
|
R.id.status_open_as -> {
|
||||||
showOpenAsDialog(statusUrl, item.title)
|
showOpenAsDialog(statusUrl, item.title)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_download_media -> {
|
R.id.status_download_media -> {
|
||||||
requestDownloadAllMedia(status)
|
requestDownloadAllMedia(status)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_mute -> {
|
R.id.status_mute -> {
|
||||||
onMute(accountId, accountUsername)
|
onMute(accountId, accountUsername)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_block -> {
|
R.id.status_block -> {
|
||||||
onBlock(accountId, accountUsername)
|
onBlock(accountId, accountUsername)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_report -> {
|
R.id.status_report -> {
|
||||||
openReportPage(accountId, accountUsername, id)
|
openReportPage(accountId, accountUsername, id)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_unreblog_private -> {
|
R.id.status_unreblog_private -> {
|
||||||
onReblog(false, position)
|
onReblog(false, position)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_reblog_private -> {
|
R.id.status_reblog_private -> {
|
||||||
onReblog(true, position)
|
onReblog(true, position)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_delete -> {
|
R.id.status_delete -> {
|
||||||
showConfirmDeleteDialog(id, position)
|
showConfirmDeleteDialog(id, position)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_delete_and_redraft -> {
|
R.id.status_delete_and_redraft -> {
|
||||||
showConfirmEditDialog(id, position, status)
|
showConfirmEditDialog(id, position, status)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_edit -> {
|
R.id.status_edit -> {
|
||||||
editStatus(id, status)
|
editStatus(id, status)
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.pin -> {
|
R.id.pin -> {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
timelineCases.pin(status.id, !status.isPinned()).onFailure { e: Throwable ->
|
timelineCases.pin(status.id, !status.isPinned())
|
||||||
val message = e.message
|
.onFailure { e: Throwable ->
|
||||||
?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin)
|
val message = e.message
|
||||||
Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show()
|
?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin)
|
||||||
}
|
Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.status_mute_conversation -> {
|
R.id.status_mute_conversation -> {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
timelineCases.muteConversation(status.id, status.muted != true)
|
timelineCases.muteConversation(status.id, status.muted != true)
|
||||||
}
|
}
|
||||||
return@setOnMenuItemClickListener true
|
return@setOnMenuItemClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.status_translate -> {
|
||||||
|
onMoreTranslate?.invoke(translation == null, position)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
@ -346,6 +396,7 @@ abstract class SFragment : Fragment(), Injectable {
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Attachment.Type.UNKNOWN -> {
|
Attachment.Type.UNKNOWN -> {
|
||||||
requireContext().openLink(attachment.url)
|
requireContext().openLink(attachment.url)
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,7 +64,8 @@ public interface StatusActionListener extends LinkListener {
|
||||||
void onVoteInPoll(int position, @NonNull List<Integer> choices);
|
void onVoteInPoll(int position, @NonNull List<Integer> choices);
|
||||||
|
|
||||||
default void onShowEdits(int position) {}
|
default void onShowEdits(int position) {}
|
||||||
|
|
||||||
void clearWarningAction(int position);
|
void clearWarningAction(int position);
|
||||||
|
|
||||||
|
void onUntranslate(int position);
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,7 @@ import com.keylesspalace.tusky.entity.StatusContext
|
||||||
import com.keylesspalace.tusky.entity.StatusEdit
|
import com.keylesspalace.tusky.entity.StatusEdit
|
||||||
import com.keylesspalace.tusky.entity.StatusSource
|
import com.keylesspalace.tusky.entity.StatusSource
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
|
import com.keylesspalace.tusky.entity.Translation
|
||||||
import com.keylesspalace.tusky.entity.TrendingTag
|
import com.keylesspalace.tusky.entity.TrendingTag
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
|
@ -703,4 +704,11 @@ interface MastodonApi {
|
||||||
@Query("limit") limit: Int? = null,
|
@Query("limit") limit: Int? = null,
|
||||||
@Query("offset") offset: String? = null
|
@Query("offset") offset: String? = null
|
||||||
): Response<List<Status>>
|
): Response<List<Status>>
|
||||||
|
|
||||||
|
@FormUrlEncoded
|
||||||
|
@POST("api/v1/statuses/{id}/translate")
|
||||||
|
suspend fun translate(
|
||||||
|
@Path("id") statusId: String,
|
||||||
|
@Field("lang") targetLanguage: String?
|
||||||
|
): NetworkResult<Translation>
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,9 +33,11 @@ import com.keylesspalace.tusky.entity.Notification
|
||||||
import com.keylesspalace.tusky.entity.Poll
|
import com.keylesspalace.tusky.entity.Poll
|
||||||
import com.keylesspalace.tusky.entity.Relationship
|
import com.keylesspalace.tusky.entity.Relationship
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.entity.Translation
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.util.Single
|
import com.keylesspalace.tusky.util.Single
|
||||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
@ -184,6 +186,12 @@ class TimelineCases @Inject constructor(
|
||||||
return Single { mastodonApi.clearNotifications() }
|
return Single { mastodonApi.clearNotifications() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun translate(
|
||||||
|
statusId: String
|
||||||
|
): NetworkResult<Translation> {
|
||||||
|
return mastodonApi.translate(statusId, Locale.getDefault().language)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "TimelineCases"
|
private const val TAG = "TimelineCases"
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,3 +80,8 @@ fun getLocaleList(initialLanguages: List<String>): List<Locale> {
|
||||||
ensureLanguagesAreFirst(locales, initialLanguages)
|
ensureLanguagesAreFirst(locales, initialLanguages)
|
||||||
return locales
|
return locales
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun localeNameForUntrustedISO639LangCode(code: String): String {
|
||||||
|
// It seems like it never throws?
|
||||||
|
return Locale(code).displayName
|
||||||
|
}
|
||||||
|
|
|
@ -41,20 +41,23 @@ import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.entity.TrendingTag
|
import com.keylesspalace.tusky.entity.TrendingTag
|
||||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||||
|
import com.keylesspalace.tusky.viewdata.TranslationViewData
|
||||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||||
|
|
||||||
fun Status.toViewData(
|
fun Status.toViewData(
|
||||||
isShowingContent: Boolean,
|
isShowingContent: Boolean,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
isCollapsed: Boolean,
|
isCollapsed: Boolean,
|
||||||
isDetailed: Boolean = false
|
isDetailed: Boolean = false,
|
||||||
|
translation: TranslationViewData? = null,
|
||||||
): StatusViewData.Concrete {
|
): StatusViewData.Concrete {
|
||||||
return StatusViewData.Concrete(
|
return StatusViewData.Concrete(
|
||||||
status = this,
|
status = this,
|
||||||
isShowingContent = isShowingContent,
|
isShowingContent = isShowingContent,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
isDetailed = isDetailed
|
isDetailed = isDetailed,
|
||||||
|
translation = translation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,11 +15,24 @@
|
||||||
package com.keylesspalace.tusky.viewdata
|
package com.keylesspalace.tusky.viewdata
|
||||||
|
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
|
import com.keylesspalace.tusky.entity.Attachment
|
||||||
import com.keylesspalace.tusky.entity.Filter
|
import com.keylesspalace.tusky.entity.Filter
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
|
import com.keylesspalace.tusky.entity.Translation
|
||||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||||
import com.keylesspalace.tusky.util.shouldTrimStatus
|
import com.keylesspalace.tusky.util.shouldTrimStatus
|
||||||
|
|
||||||
|
sealed class TranslationViewData {
|
||||||
|
abstract val data: Translation?
|
||||||
|
|
||||||
|
data class Loaded(override val data: Translation) : TranslationViewData()
|
||||||
|
|
||||||
|
data object Loading : TranslationViewData() {
|
||||||
|
override val data: Translation?
|
||||||
|
get() = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Created by charlag on 11/07/2017.
|
* Created by charlag on 11/07/2017.
|
||||||
*
|
*
|
||||||
|
@ -41,12 +54,28 @@ sealed class StatusViewData {
|
||||||
* @return Whether the post is collapsed or fully expanded.
|
* @return Whether the post is collapsed or fully expanded.
|
||||||
*/
|
*/
|
||||||
val isCollapsed: Boolean,
|
val isCollapsed: Boolean,
|
||||||
val isDetailed: Boolean = false
|
val isDetailed: Boolean = false,
|
||||||
|
val translation: TranslationViewData? = null,
|
||||||
) : StatusViewData() {
|
) : StatusViewData() {
|
||||||
override val id: String
|
override val id: String
|
||||||
get() = status.id
|
get() = status.id
|
||||||
|
|
||||||
val content: Spanned = status.actionableStatus.content.parseAsMastodonHtml()
|
val content: Spanned =
|
||||||
|
(translation?.data?.content ?: actionable.content).parseAsMastodonHtml()
|
||||||
|
|
||||||
|
val attachments: List<Attachment> =
|
||||||
|
actionable.attachments.translated { translation -> map { it.translated(translation) } }
|
||||||
|
|
||||||
|
val spoilerText: String =
|
||||||
|
actionable.spoilerText.translated { translation -> translation.spoilerWarning ?: this }
|
||||||
|
|
||||||
|
val poll = actionable.poll?.translated { translation ->
|
||||||
|
val translatedOptionsText = translation.poll ?: return@translated this
|
||||||
|
val translatedOptions = options.zip(translatedOptionsText) { option, translatedText ->
|
||||||
|
option.copy(title = translatedText)
|
||||||
|
}
|
||||||
|
copy(options = translatedOptions)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specifies whether the content of this post is long enough to be automatically
|
* Specifies whether the content of this post is long enough to be automatically
|
||||||
|
@ -91,6 +120,20 @@ sealed class StatusViewData {
|
||||||
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
||||||
return copy(isCollapsed = isCollapsed)
|
return copy(isCollapsed = isCollapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Attachment.translated(translation: Translation): Attachment {
|
||||||
|
val translatedDescription =
|
||||||
|
translation.mediaAttachments.find { it.id == id }?.description
|
||||||
|
?: return this
|
||||||
|
return copy(description = translatedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <T> T.translated(mapper: T.(Translation) -> T): T =
|
||||||
|
if (translation is TranslationViewData.Loaded) {
|
||||||
|
mapper(translation.data)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Placeholder(
|
data class Placeholder(
|
||||||
|
|
|
@ -70,7 +70,7 @@ class EditProfileViewModel @Inject constructor(
|
||||||
val headerData = MutableLiveData<Uri>()
|
val headerData = MutableLiveData<Uri>()
|
||||||
val saveData = MutableLiveData<Resource<Nothing>>()
|
val saveData = MutableLiveData<Resource<Nothing>>()
|
||||||
|
|
||||||
val instanceData: Flow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
|
val instanceData: Flow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
|
||||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||||
|
|
||||||
val isChanged = MutableStateFlow(false)
|
val isChanged = MutableStateFlow(false)
|
||||||
|
|
|
@ -103,6 +103,47 @@
|
||||||
app:layout_constraintTop_toTopOf="@id/status_display_name"
|
app:layout_constraintTop_toTopOf="@id/status_display_name"
|
||||||
tools:text="13:37" />
|
tools:text="13:37" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/status_button_untranslate"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/action_show_original"
|
||||||
|
style="@style/TuskyButton.TextButton"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/status_translation_status"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/status_translation_status"
|
||||||
|
android:layout_marginEnd="10dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"
|
||||||
|
android:minHeight="0dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_translation_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
style="@style/TextSizeSmall"
|
||||||
|
tools:text="Translated from Lang by Service"
|
||||||
|
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/status_button_untranslate"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/status_username"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:maxLines="4"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"
|
||||||
|
android:minLines="2"
|
||||||
|
android:lineSpacingMultiplier="1.1"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/status_translation_barrier"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/status_translation_barrier"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:barrierDirection="bottom"
|
||||||
|
app:constraint_referenced_ids="status_translation_status, status_button_untranslate" />
|
||||||
|
|
||||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||||
android:id="@+id/status_content_warning_description"
|
android:id="@+id/status_content_warning_description"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
@ -116,7 +157,7 @@
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
||||||
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
app:layout_constraintTop_toBottomOf="@id/status_translation_barrier"
|
||||||
tools:text="content warning which is very long and it doesn't fit"
|
tools:text="content warning which is very long and it doesn't fit"
|
||||||
tools:visibility="visible" />
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
|
|
@ -78,6 +78,46 @@
|
||||||
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
||||||
tools:text="\@ConnyDuck\@mastodon.social" />
|
tools:text="\@ConnyDuck\@mastodon.social" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/status_button_untranslate"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/action_show_original"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/status_translation_status"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/status_translation_status"
|
||||||
|
style="@style/TuskyButton.TextButton"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_translation_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
style="@style/TextSizeSmall"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
|
tools:text="Translated from blah using service"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/status_avatar"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/status_button_untranslate"
|
||||||
|
android:minLines="2"
|
||||||
|
android:lineSpacingMultiplier="1.1"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"
|
||||||
|
android:layout_marginEnd="4dp"/>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/status_translation_barrier"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:barrierDirection="bottom"
|
||||||
|
app:constraint_referenced_ids="status_translation_status, status_button_untranslate" />
|
||||||
|
|
||||||
|
|
||||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||||
android:id="@+id/status_content_warning_description"
|
android:id="@+id/status_content_warning_description"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
@ -93,7 +133,7 @@
|
||||||
android:textSize="?attr/status_text_large"
|
android:textSize="?attr/status_text_large"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/status_avatar"
|
app:layout_constraintTop_toBottomOf="@id/status_translation_barrier"
|
||||||
tools:text="CW this is a long long long long long long long long content warning" />
|
tools:text="CW this is a long long long long long long long long content warning" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
|
|
|
@ -15,6 +15,9 @@
|
||||||
<item
|
<item
|
||||||
android:id="@+id/status_copy_link"
|
android:id="@+id/status_copy_link"
|
||||||
android:title="@string/action_copy_link" />
|
android:title="@string/action_copy_link" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/status_translate"
|
||||||
|
android:title="@string/action_translate" />
|
||||||
<item
|
<item
|
||||||
android:id="@+id/status_open_as"
|
android:id="@+id/status_open_as"
|
||||||
android:title="@string/action_open_as" />
|
android:title="@string/action_open_as" />
|
||||||
|
@ -24,13 +27,16 @@
|
||||||
<item
|
<item
|
||||||
android:id="@+id/status_mute_conversation"
|
android:id="@+id/status_mute_conversation"
|
||||||
android:title="@string/action_mute_conversation" />
|
android:title="@string/action_mute_conversation" />
|
||||||
<item
|
|
||||||
android:id="@+id/status_mute"
|
<group>
|
||||||
android:title="@string/action_mute" />
|
<item
|
||||||
<item
|
android:id="@+id/status_mute"
|
||||||
android:id="@+id/status_block"
|
android:title="@string/action_mute" />
|
||||||
android:title="@string/action_block" />
|
<item
|
||||||
<item
|
android:id="@+id/status_block"
|
||||||
android:id="@+id/status_report"
|
android:title="@string/action_block" />
|
||||||
android:title="@string/action_report" />
|
<item
|
||||||
</menu>
|
android:id="@+id/status_report"
|
||||||
|
android:title="@string/action_report" />
|
||||||
|
</group>
|
||||||
|
</menu>
|
||||||
|
|
|
@ -38,4 +38,4 @@
|
||||||
<item
|
<item
|
||||||
android:id="@+id/status_delete_and_redraft"
|
android:id="@+id/status_delete_and_redraft"
|
||||||
android:title="@string/action_delete_and_redraft" />
|
android:title="@string/action_delete_and_redraft" />
|
||||||
</menu>
|
</menu>
|
||||||
|
|
|
@ -23,4 +23,5 @@
|
||||||
<item name="action_open_reblogged_by" type="id" />
|
<item name="action_open_reblogged_by" type="id" />
|
||||||
<item name="action_open_faved_by" type="id" />
|
<item name="action_open_faved_by" type="id" />
|
||||||
<item name="action_more" type="id" />
|
<item name="action_more" type="id" />
|
||||||
</resources>
|
<item name="action_untranslate" type="id" />
|
||||||
|
</resources>
|
||||||
|
|
|
@ -150,8 +150,13 @@
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string name="description_status" translatable="false">
|
<string name="description_status" translatable="false">
|
||||||
<!-- Display name, cw?, content?, poll? relative date, edited?, reposted by?, reposted?, favorited?, bookmarked?, username, media?; visibility, fav number?, reblog number?-->
|
<!--
|
||||||
%1$s; %2$s; %3$s, %15$s %4$s, %5$s, %6$s; %7$s, %8$s, %9$s, %10$s, %11$s; %12$s, %13$s, %14$s
|
%1$s|display_name %2$s|CW?; %3$s|content?, %15$s|poll?, %4$s|date,
|
||||||
|
%6$s|reposted_by?; %7$s|username, %5$s|edited?, %16$s|translated?, %8$s|reposted?, %9$s|favorited?,
|
||||||
|
%10$s|bookmarked?, %11$s|media?; %12$s|visibility, %13$s|fav_number?,
|
||||||
|
%14$s|reblog_number?
|
||||||
|
-->
|
||||||
|
%1$s; %2$s; %3$s, %15$s, %4$s, %6$s; %7$s, %5$s, %16$s, %8$s, %9$s, %10$s, %11$s; %12$s, %13$s, %14$s
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<string-array name="rick_roll_domains" translatable="false">
|
<string-array name="rick_roll_domains" translatable="false">
|
||||||
|
|
|
@ -211,6 +211,8 @@
|
||||||
<string name="action_copy_link">Copy the link</string>
|
<string name="action_copy_link">Copy the link</string>
|
||||||
<string name="action_open_as">Open as %s</string>
|
<string name="action_open_as">Open as %s</string>
|
||||||
<string name="action_share_as">Share as …</string>
|
<string name="action_share_as">Share as …</string>
|
||||||
|
<string name="action_translate">Translate</string>
|
||||||
|
<string name="action_show_original">Show original</string>
|
||||||
<string name="download_media">Download media</string>
|
<string name="download_media">Download media</string>
|
||||||
<string name="downloading_media">Downloading media</string>
|
<string name="downloading_media">Downloading media</string>
|
||||||
|
|
||||||
|
@ -557,6 +559,9 @@
|
||||||
<item quantity="other"><b>%s</b> Boosts</item>
|
<item quantity="other"><b>%s</b> Boosts</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="label_translating">Translating…</string>
|
||||||
|
<string name="label_translated">Translated from %1$s with %2$s</string>
|
||||||
|
|
||||||
<string name="title_reblogged_by">Boosted by</string>
|
<string name="title_reblogged_by">Boosted by</string>
|
||||||
<string name="title_favourited_by">Favorited by</string>
|
<string name="title_favourited_by">Favorited by</string>
|
||||||
|
|
||||||
|
@ -798,6 +803,7 @@
|
||||||
<string name="ui_error_vote">Voting in poll failed: %s</string>
|
<string name="ui_error_vote">Voting in poll failed: %s</string>
|
||||||
<string name="ui_error_accept_follow_request">Accepting follow request failed: %s</string>
|
<string name="ui_error_accept_follow_request">Accepting follow request failed: %s</string>
|
||||||
<string name="ui_error_reject_follow_request">Rejecting follow request failed: %s</string>
|
<string name="ui_error_reject_follow_request">Rejecting follow request failed: %s</string>
|
||||||
|
<string name="ui_error_translate">Could not translate: %s</string>
|
||||||
|
|
||||||
<!-- Success messages, displayed in snackbars, when an action succeeded -->
|
<!-- Success messages, displayed in snackbars, when an action succeeded -->
|
||||||
<string name="ui_success_accepted_follow_request">Follow request accepted</string>
|
<string name="ui_success_accepted_follow_request">Follow request accepted</string>
|
||||||
|
|
|
@ -38,6 +38,8 @@ import com.keylesspalace.tusky.entity.InstanceV1
|
||||||
import com.keylesspalace.tusky.entity.StatusConfiguration
|
import com.keylesspalace.tusky.entity.StatusConfiguration
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
@ -125,7 +127,7 @@ class ComposeActivityTest {
|
||||||
|
|
||||||
val instanceDaoMock: InstanceDao = mock {
|
val instanceDaoMock: InstanceDao = mock {
|
||||||
onBlocking { getInstanceInfo(any()) } doReturn
|
onBlocking { getInstanceInfo(any()) } doReturn
|
||||||
InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null, null, null, null, null, null, null, null)
|
InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null)
|
||||||
onBlocking { getEmojiInfo(any()) } doReturn
|
onBlocking { getEmojiInfo(any()) } doReturn
|
||||||
EmojisEntity(instanceDomain, emptyList())
|
EmojisEntity(instanceDomain, emptyList())
|
||||||
}
|
}
|
||||||
|
@ -134,7 +136,7 @@ class ComposeActivityTest {
|
||||||
on { instanceDao() } doReturn instanceDaoMock
|
on { instanceDao() } doReturn instanceDaoMock
|
||||||
}
|
}
|
||||||
|
|
||||||
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock)
|
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock, CoroutineScope(SupervisorJob()))
|
||||||
|
|
||||||
val viewModel = ComposeViewModel(
|
val viewModel = ComposeViewModel(
|
||||||
apiMock,
|
apiMock,
|
||||||
|
|
Loading…
Reference in New Issue