Machine translation of posts (#4307)
This commit is contained in:
parent
80982d061e
commit
fbb22799dc
File diff suppressed because it is too large
Load Diff
|
@ -55,7 +55,7 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
|
||||
lifecycleScope.launch {
|
||||
accountManager.activeAccount?.let { account ->
|
||||
val instanceInfo = instanceInfoRepository.getInstanceInfo()
|
||||
val instanceInfo = instanceInfoRepository.getUpdatedInstanceInfoOrFallback()
|
||||
binding.accountInfo.text = getString(
|
||||
R.string.about_account_info,
|
||||
account.username,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?,
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?,
|
||||
)
|
||||
|
|
|
@ -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>?
|
||||
) {
|
||||
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package com.keylesspalace.tusky.entity
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class MediaTranslation(
|
||||
val id: String,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the result of machine translating some status content.
|
||||
*
|
||||
* See [doc](https://docs.joinmastodon.org/entities/Translation/).
|
||||
*/
|
||||
data class Translation(
|
||||
val content: String,
|
||||
@SerializedName("spoiler_warning")
|
||||
val spoilerWarning: String?,
|
||||
val poll: List<String>?,
|
||||
@SerializedName("media_attachments")
|
||||
val mediaAttachments: List<MediaTranslation>,
|
||||
@SerializedName("detected_source_language")
|
||||
val detectedSourceLanguage: String,
|
||||
val provider: String,
|
||||
)
|
|
@ -109,6 +109,7 @@ import at.connyduck.sparkbutton.helpers.Utils;
|
|||
import kotlin.Unit;
|
||||
import kotlin.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 &&
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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" />
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -38,4 +38,4 @@
|
|||
<item
|
||||
android:id="@+id/status_delete_and_redraft"
|
||||
android:title="@string/action_delete_and_redraft" />
|
||||
</menu>
|
||||
</menu>
|
||||
|
|
|
@ -23,4 +23,5 @@
|
|||
<item name="action_open_reblogged_by" type="id" />
|
||||
<item name="action_open_faved_by" type="id" />
|
||||
<item name="action_more" type="id" />
|
||||
</resources>
|
||||
<item name="action_untranslate" type="id" />
|
||||
</resources>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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"><b>%s</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>
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue