Machine translation of posts (#4307)

This commit is contained in:
Willow 2024-03-09 16:12:18 +01:00 committed by GitHub
parent 80982d061e
commit fbb22799dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1912 additions and 180 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">&lt;b>%s&lt;/b> Boosts</item> <item quantity="other">&lt;b>%s&lt;/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>

View File

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