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 {
accountManager.activeAccount?.let { account ->
val instanceInfo = instanceInfoRepository.getInstanceInfo()
val instanceInfo = instanceInfoRepository.getUpdatedInstanceInfoOrFallback()
binding.accountInfo.text = getString(
R.string.about_account_info,
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.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.Translation;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
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.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.LocaleUtilsKt;
import com.keylesspalace.tusky.util.NumberUtils;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
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.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.keylesspalace.tusky.viewdata.TranslationViewData;
import java.text.NumberFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.helpers.Utils;
@ -120,6 +124,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected final TextView filteredPlaceholderLabel;
protected final Button filteredPlaceholderShowButton;
protected final ConstraintLayout statusContainer;
private final TextView translationStatusView;
private final Button untranslateButton;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
@ -182,6 +189,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
((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.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
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) {
Status actionable = status.getActionable();
String spoilerText = actionable.getSpoilerText();
String spoilerText = status.getSpoilerText();
List<Emoji> emojis = actionable.getEmojis();
boolean sensitive = !TextUtils.isEmpty(spoilerText);
@ -273,7 +283,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
List<Status.Mention> mentions = actionable.getMentions();
List<HashTag> tags = actionable.getTags();
List<Emoji> emojis = actionable.getEmojis();
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
@ -779,7 +789,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setReblogged(actionable.getReblogged());
setFavourited(actionable.getFavourited());
setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = actionable.getAttachments();
List<Attachment> attachments = status.getAttachments();
boolean sensitive = actionable.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
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(),
statusDisplayOptions);
setTranslationStatus(status, listener);
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
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) {
if (status.getFilterAction() != Filter.Action.WARN) {
showFilteredPlaceholder(false);
@ -864,27 +900,57 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Status actionable = status.getActionable();
String description = context.getString(R.string.description_status,
// 1 display_name
actionable.getAccount().getDisplayName(),
// 2 CW?
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),
// 5 edited?
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
// 6 reposted_by?
getReblogDescription(context, status),
// 7 username
actionable.getAccount().getUsername(),
// 8 reposted
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
// 9 favorited
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
// 10 bookmarked
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
// 11 media
getMediaDescription(context, status),
// 12 visibility
getVisibilityDescription(context, actionable.getVisibility()),
// 13 fav_number
getFavsText(context, actionable.getFavouritesCount()),
// 14 reblog_number
getReblogsText(context, actionable.getReblogsCount()),
getPollDescription(status, context, statusDisplayOptions)
// 15 poll?
getPollDescription(status, context, statusDisplayOptions),
// 16 translated?
getTranslatedDescription(context, status.getTranslation())
);
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,
@NonNull StatusViewData.Concrete status) {
@Nullable
Status reblog = status.getRebloggingStatus();
if (reblog != null) {
return context
@ -895,12 +961,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
private static CharSequence getMediaDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (status.getActionable().getAttachments().isEmpty()) {
@NonNull StatusViewData.Concrete viewData) {
if (viewData.getAttachments().isEmpty()) {
return "";
}
StringBuilder mediaDescriptions = CollectionsKt.fold(
status.getActionable().getAttachments(),
viewData.getAttachments(),
new StringBuilder(),
(builder, a) -> {
if (a.getDescription() == null) {
@ -917,8 +983,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getContentWarningDescription(Context context,
@NonNull StatusViewData.Concrete status) {
if (!TextUtils.isEmpty(status.getActionable().getSpoilerText())) {
return context.getString(R.string.description_post_cw, status.getActionable().getSpoilerText());
if (!TextUtils.isEmpty(status.getSpoilerText())) {
return context.getString(R.string.description_post_cw, status.getSpoilerText());
} else {
return "";
}
@ -954,7 +1020,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
Context context,
StatusDisplayOptions statusDisplayOptions) {
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
if (poll == null) {
return "";
} else {
@ -981,7 +1047,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
@NonNull
protected CharSequence getReblogsText (@NonNull Context context, int count) {
protected CharSequence getReblogsText(@NonNull Context context, int count) {
String countString = numberFormat.format(count);
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.ImageSpan;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.ViewUtils;
import androidx.recyclerview.widget.RecyclerView;
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.NoUnderlineURLSpan;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ViewExtensionsKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.DateFormat;

View File

@ -80,7 +80,7 @@ class ComposeViewModel @Inject constructor(
private var currentContent: 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)
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()

View File

@ -136,7 +136,11 @@ class ConversationsFragment :
if (loadState.isAnyLoading()) {
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)
}
}
is LoadState.Error -> {
binding.statusView.show()
binding.statusView.setup(
(loadState.refresh as LoadState.Error).error
) { refreshContent() }
}
is LoadState.Loading -> {
binding.progressBar.show()
}
@ -242,6 +248,7 @@ class ConversationsFragment :
refreshContent()
true
}
else -> false
}
}
@ -256,7 +263,8 @@ class ConversationsFragment :
(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() {
@ -284,6 +292,8 @@ class ConversationsFragment :
}
}
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
override fun onMore(view: View, position: Int) {
adapter.peek(position)?.let { conversation ->
@ -386,6 +396,10 @@ class ConversationsFragment :
}
}
override fun onUntranslate(position: Int) {
// not needed
}
private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
@ -402,6 +416,7 @@ class ConversationsFragment :
PrefKeys.FAB_HIDE -> {
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
}
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled

View File

@ -29,5 +29,6 @@ data class InstanceInfo(
val maxFields: Int,
val maxFieldNameLength: 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
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.getOrThrow
import at.connyduck.calladapter.networkresult.map
import at.connyduck.calladapter.networkresult.onSuccess
import at.connyduck.calladapter.networkresult.recover
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.di.ApplicationScope
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.util.isHttpNotFound
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Singleton
class InstanceInfoRepository @Inject constructor(
private val api: MastodonApi,
db: AppDatabase,
accountManager: AccountManager
private val accountManager: AccountManager,
@ApplicationScope
private val externalScope: CoroutineScope
) {
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.
@ -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.
* Never throws, returns defaults of vanilla Mastodon in case of error.
*/
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
api.getInstance()
.fold(
{ instance ->
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 ->
suspend fun getUpdatedInstanceInfoOrFallback(): InstanceInfo =
withContext(Dispatchers.IO) {
fetchAndPersistInstanceInfo()
.getOrElse { throwable ->
Log.w(
TAG,
"failed to instance, falling back to cache and default values",
"failed to load instance, falling back to cache and default values",
throwable
)
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 {
private const val TAG = "InstanceInfoRepo"

View File

@ -23,7 +23,9 @@ import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.map
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.db.AccountEntity
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.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import javax.inject.Inject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@ -41,9 +44,14 @@ import kotlinx.coroutines.launch
class SearchViewModel @Inject constructor(
mastodonApi: MastodonApi,
private val timelineCases: TimelineCases,
private val accountManager: AccountManager
private val accountManager: AccountManager,
private val instanceInfoRepository: InstanceInfoRepository,
) : ViewModel() {
init {
instanceInfoRepository.precache()
}
var currentQuery: String = ""
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) {
val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id }
if (idx >= 0) {

View File

@ -39,6 +39,7 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
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.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@ -96,13 +99,24 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
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(
DividerItemDecoration(
binding.searchRecyclerView.context,
DividerItemDecoration.VERTICAL
)
)
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
binding.searchRecyclerView.layoutManager =
LinearLayoutManager(binding.searchRecyclerView.context)
return SearchStatusesAdapter(statusDisplayOptions, this)
}
@ -131,7 +145,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
override fun onMore(view: View, position: Int) {
searchAdapter.peek(position)?.status?.let {
searchAdapter.peek(position)?.let {
more(it, view, position)
}
}
@ -159,6 +173,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
startActivity(intent)
}
}
Attachment.Type.UNKNOWN -> {
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 {
fun newInstance() = SearchStatusesFragment()
}
@ -244,7 +265,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
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 accountId = status.actionableStatus.account.id
val accountUsername = status.actionableStatus.account.username
@ -266,12 +288,14 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
)
menu.add(0, R.id.pin, 1, textId)
}
Status.Visibility.PRIVATE -> {
var reblogged = status.reblogged
if (status.reblog != null) reblogged = status.reblog.reblogged
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
}
Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> {
} // Ignore
}
@ -289,7 +313,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
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 {
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 ->
when (item.itemId) {
R.id.post_share_content -> {
@ -324,6 +357,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
)
return@setOnMenuItemClickListener true
}
R.id.post_share_link -> {
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
@ -337,6 +371,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
)
return@setOnMenuItemClickListener true
}
R.id.status_copy_link -> {
val clipboard = requireActivity().getSystemService(
Context.CLIPBOARD_SERVICE
@ -344,56 +379,85 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl))
return@setOnMenuItemClickListener true
}
R.id.status_open_as -> {
showOpenAsDialog(statusUrl!!, item.title)
return@setOnMenuItemClickListener true
}
R.id.status_download_media -> {
requestDownloadAllMedia(status)
return@setOnMenuItemClickListener true
}
R.id.status_mute_conversation -> {
searchAdapter.peek(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, status.muted != true)
}
return@setOnMenuItemClickListener true
}
R.id.status_mute -> {
onMute(accountId, accountUsername)
return@setOnMenuItemClickListener true
}
R.id.status_block -> {
onBlock(accountId, accountUsername)
return@setOnMenuItemClickListener true
}
R.id.status_report -> {
openReportPage(accountId, accountUsername, id)
return@setOnMenuItemClickListener true
}
R.id.status_unreblog_private -> {
onReblog(false, position)
return@setOnMenuItemClickListener true
}
R.id.status_reblog_private -> {
onReblog(true, position)
return@setOnMenuItemClickListener true
}
R.id.status_delete -> {
showConfirmDeleteDialog(id, position)
return@setOnMenuItemClickListener true
}
R.id.status_delete_and_redraft -> {
showConfirmEditDialog(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.status_edit -> {
editStatus(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.pin -> {
viewModel.pinAccount(status, !status.isPinned())
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
}

View File

@ -36,8 +36,10 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
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.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -238,12 +241,14 @@ class TimelineFragment :
}
}
}
is LoadState.Error -> {
binding.statusView.show()
binding.statusView.setup(
(loadState.refresh as LoadState.Error).error
) { onRefresh() }
}
is LoadState.Loading -> {
binding.progressBar.show()
}
@ -306,6 +311,7 @@ class TimelineFragment :
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
@ -348,6 +354,7 @@ class TimelineFragment :
false
}
}
else -> false
}
}
@ -415,6 +422,17 @@ class TimelineFragment :
adapter.refresh()
}
override val onMoreTranslate =
{ translate: Boolean, position: Int ->
if (translate) {
onTranslate(position)
} else {
onUntranslate(
position
)
}
}
override fun onReply(position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.reply(status.status)
@ -425,6 +443,25 @@ class TimelineFragment :
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) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
@ -447,7 +484,12 @@ class TimelineFragment :
override fun onMore(view: View, position: Int) {
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) {
@ -480,7 +522,8 @@ class TimelineFragment :
override fun onLoadMore(position: Int) {
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
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)
}
@ -533,6 +576,7 @@ class TimelineFragment :
PrefKeys.FAB_HIDE -> {
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
}
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
@ -541,6 +585,7 @@ class TimelineFragment :
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
}
PrefKeys.READING_ORDER -> {
readingOrder = ReadingOrder.from(
sharedPreferences.getString(PrefKeys.READING_ORDER, null)
@ -555,10 +600,12 @@ class TimelineFragment :
TimelineViewModel.Kind.PUBLIC_FEDERATED,
TimelineViewModel.Kind.PUBLIC_LOCAL,
TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh()
TimelineViewModel.Kind.USER,
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
adapter.refresh()
}
TimelineViewModel.Kind.TAG,
TimelineViewModel.Kind.FAVOURITES,
TimelineViewModel.Kind.LIST,
@ -583,13 +630,14 @@ class TimelineFragment :
override fun onPause() {
super.onPause()
(binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position ->
if (position != RecyclerView.NO_POSITION) {
adapter.snapshot().getOrNull(position)?.id?.let { statusId ->
viewModel.saveReadingPosition(statusId)
(binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()
?.let { position ->
if (position != RecyclerView.NO_POSITION) {
adapter.snapshot().getOrNull(position)?.id?.let { statusId ->
viewModel.saveReadingPosition(statusId)
}
}
}
}
}
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.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import java.util.Date
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) {
Log.d(TAG, "Constructing 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,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered
filtered = status.filtered,
)
}
val status = if (reblog != null) {
@ -244,7 +245,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content.orEmpty(),
content = translation?.data?.content ?: status.content.orEmpty(),
createdAt = Date(status.createdAt),
editedAt = status.editedAt?.let { Date(it) },
emojis = emojis,
@ -274,6 +275,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
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.map
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.keylesspalace.tusky.appstore.EventHub
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.util.EmptyPagingSource
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.HttpException
@ -76,6 +81,9 @@ class CachedTimelineViewModel @Inject constructor(
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
/** Map from status id to translation. */
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
@OptIn(ExperimentalPagingApi::class)
override val statuses = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE),
@ -91,15 +99,24 @@ class CachedTimelineViewModel @Inject constructor(
}
}
).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 ->
timelineStatus.toViewData(gson)
val translation = translations[timelineStatus.status.serverId]
timelineStatus.toViewData(
gson,
isDetailed = false,
translation = translation
)
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
shouldFilterStatus(statusViewData) != Filter.Action.HIDE
}
}
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope)
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
// 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 {
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.cachedIn
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.components.timeline.util.ifExpected
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.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
@ -145,7 +149,8 @@ class NetworkTimelineViewModel @Inject constructor(
try {
val placeholderIndex =
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
@ -178,7 +183,9 @@ class NetworkTimelineViewModel @Inject constructor(
val overlappedFrom = statusData.indexOfFirst {
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) {
data.mapIndexed { i, status ->
@ -198,12 +205,18 @@ class NetworkTimelineViewModel @Inject constructor(
statusData.removeAll { status ->
when (status) {
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(
firstId
)
is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(
firstId
)
}
}
} 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()
}
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)
suspend fun fetchStatusesForKind(
fromId: String?,
@ -273,6 +301,7 @@ class NetworkTimelineViewModel @Inject constructor(
val additionalHashtags = tags.subList(1, tags.size)
api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit)
}
Kind.USER -> api.accountStatuses(
id!!,
fromId,
@ -282,6 +311,7 @@ class NetworkTimelineViewModel @Inject constructor(
onlyMedia = null,
pinned = null
)
Kind.USER_PINNED -> api.accountStatuses(
id!!,
fromId,
@ -291,6 +321,7 @@ class NetworkTimelineViewModel @Inject constructor(
onlyMedia = null,
pinned = true
)
Kind.USER_WITH_REPLIES -> api.accountStatuses(
id!!,
fromId,
@ -300,6 +331,7 @@ class NetworkTimelineViewModel @Inject constructor(
onlyMedia = null,
pinned = null
)
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
@ -308,7 +340,8 @@ class NetworkTimelineViewModel @Inject constructor(
}
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
currentSource?.invalidate()
}

View File

@ -20,6 +20,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import at.connyduck.calladapter.networkresult.getOrThrow
@ -52,7 +53,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
abstract class TimelineViewModel(
private val timelineCases: TimelineCases,
protected val timelineCases: TimelineCases,
private val api: MastodonApi,
private val eventHub: EventHub,
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 {
private const val TAG = "TimelineVM"
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.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.calladapter.networkresult.onFailure
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
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.viewdata.AttachmentViewData.Companion.list
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import javax.inject.Inject
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
@ -166,6 +168,7 @@ class ViewThreadFragment :
initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
initialProgressBar.start()
}
is ThreadUiState.LoadingThread -> {
if (uiState.statusViewDatum == null) {
// no detailed statuses available, e.g. because author is blocked
@ -189,6 +192,7 @@ class ViewThreadFragment :
binding.recyclerView.show()
binding.statusView.hide()
}
is ThreadUiState.Error -> {
Log.w(TAG, "failed to load status", uiState.throwable)
initialProgressBar.cancel()
@ -204,6 +208,7 @@ class ViewThreadFragment :
uiState.throwable
) { viewModel.retry(thisThreadsStatusId) }
}
is ThreadUiState.Success -> {
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
// no detailed statuses available, e.g. because author is blocked
@ -231,6 +236,7 @@ class ViewThreadFragment :
binding.recyclerView.show()
binding.statusView.hide()
}
is ThreadUiState.Refreshing -> {
threadProgressBar.cancel()
}
@ -270,14 +276,17 @@ class ViewThreadFragment :
viewModel.toggleRevealButton()
true
}
R.id.action_open_in_web -> {
context?.openLink(requireArguments().getString(URL_EXTRA)!!)
true
}
R.id.action_refresh -> {
onRefresh()
true
}
else -> false
}
}
@ -323,6 +332,36 @@ class ViewThreadFragment :
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) {
val status = adapter.currentList[position]
viewModel.favorite(favourite, status)
@ -334,7 +373,13 @@ class ViewThreadFragment :
}
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?) {

View File

@ -18,9 +18,12 @@ package com.keylesspalace.tusky.components.viewthread
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
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.keylesspalace.tusky.appstore.BlockEvent
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.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
@ -110,7 +114,7 @@ class ViewThreadViewModel @Inject constructor(
Log.d(TAG, "Loaded status from local timeline")
val viewData = timelineStatus.toViewData(
gson,
isDetailed = true
isDetailed = true,
) as StatusViewData.Concrete
// 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()
contextResult.fold({ statusContext ->
val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter()
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter()
val ancestors =
statusContext.ancestors.map { status -> status.toViewData() }.filter()
val descendants =
statusContext.descendants.map { status -> status.toViewData() }.filter()
val statuses = ancestors + detailedStatus + descendants
_uiState.value = ThreadUiState.Success(
@ -189,6 +195,7 @@ class ViewThreadViewModel @Inject constructor(
is ThreadUiState.Success -> uiState.statusViewData.find { status ->
status.isDetailed
}
is ThreadUiState.LoadingThread -> uiState.statusViewDatum
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) {
updateStatusViewData(status.id) { viewData ->
status.toViewData(
isShowingContent = viewData.isShowingContent,
isExpanded = viewData.isExpanded,
isCollapsed = viewData.isCollapsed,
isDetailed = viewData.isDetailed
isDetailed = viewData.isDetailed,
translation = viewData.translation,
)
}
}
@ -307,7 +338,8 @@ class ViewThreadViewModel @Inject constructor(
updateSuccess { uiState ->
val statuses = uiState.statusViewData
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) {
// there is a new reply to the detailed status or below -> display it
val newStatuses = statuses.subList(0, repliedIndex + 1) +
@ -339,12 +371,14 @@ class ViewThreadViewModel @Inject constructor(
},
revealButton = RevealButtonState.REVEAL
)
RevealButtonState.REVEAL -> uiState.copy(
statusViewData = uiState.statusViewData.map { viewData ->
viewData.copy(isExpanded = true)
},
revealButton = RevealButtonState.HIDE
)
else -> uiState
}
}
@ -441,7 +475,8 @@ class ViewThreadViewModel @Inject constructor(
it.id == this.id
}
return toViewData(
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
isShowingContent = oldStatus?.isShowingContent
?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
isCollapsed = oldStatus?.isCollapsed ?: !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.
// This is to reserve odd version numbers for use by forks.
version = 56,
version = 58,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@AutoMigration(from = 50, to = 51),
@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 {

View File

@ -38,7 +38,8 @@ data class InstanceEntity(
val maxMediaAttachments: Int?,
val maxFields: Int?,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
val maxFieldValueLength: Int?,
val translationEnabled: Boolean?,
)
@TypeConverters(Converters::class)
@ -62,5 +63,6 @@ data class InstanceInfoEntity(
val maxMediaAttachments: Int?,
val maxFields: 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 muted: Boolean?,
val poll: Poll?,
/** Preview card for links included within status content. */
val card: Card?,
/** ISO 639 language code for this status. */
val language: String?,
/** If the current token has an authorized user: The filter and keywords that matched this status. */
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.collections.CollectionsKt;
import kotlin.jvm.functions.Function1;
import kotlin.jvm.functions.Function2;
import kotlinx.coroutines.Job;
public class NotificationsFragment extends SFragment implements
@ -408,6 +409,12 @@ public class NotificationsFragment extends SFragment implements
sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1);
}
@Nullable
@Override
protected Function2<Boolean, Integer, Unit> getOnMoreTranslate() {
return null;
}
@Override
public void onReply(int position) {
super.reply(notifications.get(position).asRight().getStatus());
@ -490,7 +497,7 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onMore(@NonNull View view, int position) {
Notification notification = notifications.get(position).asRight();
super.more(notification.getStatus(), view, position);
super.more(notification.getStatus(), view, position, null);
}
@Override
@ -525,10 +532,6 @@ public class NotificationsFragment extends SFragment implements
updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing));
}
private void setPinForStatus(String statusId, boolean pinned) {
updateStatus(statusId, status -> status.copyWithPinned(pinned));
}
@Override
public void onLoadMore(int position) {
// Check bounds before accessing list,
@ -555,6 +558,11 @@ public class NotificationsFragment extends SFragment implements
updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed));
}
@Override
public void onUntranslate(int position) {
// not needed
}
private void updateStatus(String statusId, Function<Status, Status> mapper) {
int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() &&
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.Companion.startIntent
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.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.Translation
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.network.MastodonApi
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.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.launch
@ -72,6 +75,10 @@ import kotlinx.coroutines.launch
abstract class SFragment : Fragment(), Injectable {
protected abstract fun removeItem(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
@Inject
@ -83,6 +90,9 @@ abstract class SFragment : Fragment(), Injectable {
@Inject
lateinit var timelineCases: TimelineCases
@Inject
lateinit var instanceInfoRepository: InstanceInfoRepository
override fun startActivity(intent: 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?) {
if (status == null) return
bottomSheetActivity.viewAccount(status.account.id)
@ -140,7 +157,7 @@ abstract class SFragment : Fragment(), Injectable {
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 accountId = status.actionableStatus.account.id
val accountUsername = status.actionableStatus.account.username
@ -167,16 +184,19 @@ abstract class SFragment : Fragment(), Injectable {
)
)
}
Status.Visibility.PRIVATE -> {
val reblogged = status.reblog?.reblogged ?: status.reblogged
menu.findItem(R.id.status_reblog_private).isVisible = !reblogged
menu.findItem(R.id.status_unreblog_private).isVisible = reblogged
}
else -> {}
}
} else {
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 openAsItem = menu.findItem(R.id.status_open_as)
@ -187,7 +207,8 @@ abstract class SFragment : Fragment(), Injectable {
openAsItem.title = openAsText
}
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
if (mutable) {
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 ->
when (item.itemId) {
R.id.post_share_content -> {
@ -219,6 +249,7 @@ abstract class SFragment : Fragment(), Injectable {
)
return@setOnMenuItemClickListener true
}
R.id.post_share_link -> {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
@ -233,6 +264,7 @@ abstract class SFragment : Fragment(), Injectable {
)
return@setOnMenuItemClickListener true
}
R.id.status_copy_link -> {
(
requireActivity().getSystemService(
@ -243,62 +275,80 @@ abstract class SFragment : Fragment(), Injectable {
}
return@setOnMenuItemClickListener true
}
R.id.status_open_as -> {
showOpenAsDialog(statusUrl, item.title)
return@setOnMenuItemClickListener true
}
R.id.status_download_media -> {
requestDownloadAllMedia(status)
return@setOnMenuItemClickListener true
}
R.id.status_mute -> {
onMute(accountId, accountUsername)
return@setOnMenuItemClickListener true
}
R.id.status_block -> {
onBlock(accountId, accountUsername)
return@setOnMenuItemClickListener true
}
R.id.status_report -> {
openReportPage(accountId, accountUsername, id)
return@setOnMenuItemClickListener true
}
R.id.status_unreblog_private -> {
onReblog(false, position)
return@setOnMenuItemClickListener true
}
R.id.status_reblog_private -> {
onReblog(true, position)
return@setOnMenuItemClickListener true
}
R.id.status_delete -> {
showConfirmDeleteDialog(id, position)
return@setOnMenuItemClickListener true
}
R.id.status_delete_and_redraft -> {
showConfirmEditDialog(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.status_edit -> {
editStatus(id, status)
return@setOnMenuItemClickListener true
}
R.id.pin -> {
lifecycleScope.launch {
timelineCases.pin(status.id, !status.isPinned()).onFailure { e: Throwable ->
val message = e.message
?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin)
Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show()
}
timelineCases.pin(status.id, !status.isPinned())
.onFailure { e: Throwable ->
val message = e.message
?: 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
}
R.id.status_mute_conversation -> {
lifecycleScope.launch {
timelineCases.muteConversation(status.id, status.muted != true)
}
return@setOnMenuItemClickListener true
}
R.id.status_translate -> {
onMoreTranslate?.invoke(translation == null, position)
}
}
false
}
@ -346,6 +396,7 @@ abstract class SFragment : Fragment(), Injectable {
startActivity(intent)
}
}
Attachment.Type.UNKNOWN -> {
requireContext().openLink(attachment.url)
}

View File

@ -64,7 +64,8 @@ public interface StatusActionListener extends LinkListener {
void onVoteInPoll(int position, @NonNull List<Integer> choices);
default void onShowEdits(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.StatusSource
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.entity.Translation
import com.keylesspalace.tusky.entity.TrendingTag
import okhttp3.MultipartBody
import okhttp3.RequestBody
@ -703,4 +704,11 @@ interface MastodonApi {
@Query("limit") limit: Int? = null,
@Query("offset") offset: String? = null
): 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.Relationship
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.Translation
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Single
import com.keylesspalace.tusky.util.getServerErrorMessage
import java.util.Locale
import javax.inject.Inject
import okhttp3.ResponseBody
import retrofit2.Response
@ -184,6 +186,12 @@ class TimelineCases @Inject constructor(
return Single { mastodonApi.clearNotifications() }
}
suspend fun translate(
statusId: String
): NetworkResult<Translation> {
return mastodonApi.translate(statusId, Locale.getDefault().language)
}
companion object {
private const val TAG = "TimelineCases"
}

View File

@ -80,3 +80,8 @@ fun getLocaleList(initialLanguages: List<String>): List<Locale> {
ensureLanguagesAreFirst(locales, initialLanguages)
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.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData
fun Status.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean,
isDetailed: Boolean = false
isDetailed: Boolean = false,
translation: TranslationViewData? = null,
): StatusViewData.Concrete {
return StatusViewData.Concrete(
status = this,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isExpanded = isExpanded,
isDetailed = isDetailed
isDetailed = isDetailed,
translation = translation,
)
}

View File

@ -15,11 +15,24 @@
package com.keylesspalace.tusky.viewdata
import android.text.Spanned
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.Translation
import com.keylesspalace.tusky.util.parseAsMastodonHtml
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.
*
@ -41,12 +54,28 @@ sealed class StatusViewData {
* @return Whether the post is collapsed or fully expanded.
*/
val isCollapsed: Boolean,
val isDetailed: Boolean = false
val isDetailed: Boolean = false,
val translation: TranslationViewData? = null,
) : StatusViewData() {
override val id: String
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
@ -91,6 +120,20 @@ sealed class StatusViewData {
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
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(

View File

@ -70,7 +70,7 @@ class EditProfileViewModel @Inject constructor(
val headerData = MutableLiveData<Uri>()
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)
val isChanged = MutableStateFlow(false)

View File

@ -103,6 +103,47 @@
app:layout_constraintTop_toTopOf="@id/status_display_name"
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
android:id="@+id/status_content_warning_description"
android:layout_width="0dp"
@ -116,7 +157,7 @@
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
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:visibility="visible" />

View File

@ -78,6 +78,46 @@
app:layout_constraintTop_toBottomOf="@id/status_display_name"
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
android:id="@+id/status_content_warning_description"
android:layout_width="0dp"
@ -93,7 +133,7 @@
android:textSize="?attr/status_text_large"
app:layout_constraintEnd_toEndOf="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" />
<com.google.android.material.button.MaterialButton

View File

@ -15,6 +15,9 @@
<item
android:id="@+id/status_copy_link"
android:title="@string/action_copy_link" />
<item
android:id="@+id/status_translate"
android:title="@string/action_translate" />
<item
android:id="@+id/status_open_as"
android:title="@string/action_open_as" />
@ -24,13 +27,16 @@
<item
android:id="@+id/status_mute_conversation"
android:title="@string/action_mute_conversation" />
<item
android:id="@+id/status_mute"
android:title="@string/action_mute" />
<item
android:id="@+id/status_block"
android:title="@string/action_block" />
<item
android:id="@+id/status_report"
android:title="@string/action_report" />
</menu>
<group>
<item
android:id="@+id/status_mute"
android:title="@string/action_mute" />
<item
android:id="@+id/status_block"
android:title="@string/action_block" />
<item
android:id="@+id/status_report"
android:title="@string/action_report" />
</group>
</menu>

View File

@ -38,4 +38,4 @@
<item
android:id="@+id/status_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_faved_by" 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 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-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_open_as">Open as %s</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="downloading_media">Downloading media</string>
@ -557,6 +559,9 @@
<item quantity="other">&lt;b>%s&lt;/b> Boosts</item>
</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_favourited_by">Favorited by</string>
@ -798,6 +803,7 @@
<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_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 -->
<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.network.MastodonApi
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
@ -125,7 +127,7 @@ class ComposeActivityTest {
val instanceDaoMock: InstanceDao = mock {
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
EmojisEntity(instanceDomain, emptyList())
}
@ -134,7 +136,7 @@ class ComposeActivityTest {
on { instanceDao() } doReturn instanceDaoMock
}
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock)
val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock, CoroutineScope(SupervisorJob()))
val viewModel = ComposeViewModel(
apiMock,