feat: Translate statuses on cached timelines (#220)
Implement some support for server-side status translation. Do this by: - Implement support for the `api/v1/instance` endpoint to determine if the remote server supports translation. - Create new `ServerCapabilities` to allow the app to query the remote capabilities in a server-agnostic way. Use this to query if the remote server supports the Mastodon implementation of server-side translation - If translation is supported then show a translate/undo translate option on the status "..." menu. - Fetch translated content from the server if requested, and store it locally as a new Room entity. - Update displaying a status to check if the translated version should be displayed; if it should then new code is used to show translated content, content warning, poll options, and media descriptions. - Add a `TextView` to show an "in progress" message while translation is happening, and to show the translation provider (generally required by agreements with them). Partially fixes #62 --------- Co-authored-by: sanao <naosak1006@gmail.com>
This commit is contained in:
parent
27367d94bd
commit
d40b87f0a0
|
@ -224,6 +224,9 @@ dependencies {
|
|||
googleImplementation libs.app.update
|
||||
googleImplementation libs.app.update.ktx
|
||||
|
||||
implementation libs.kotlin.result
|
||||
implementation libs.semver
|
||||
|
||||
testImplementation libs.androidx.test.junit
|
||||
testImplementation libs.robolectric
|
||||
testImplementation libs.bundles.mockito
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -30,73 +30,71 @@ import app.pachli.viewdata.buildDescription
|
|||
import app.pachli.viewdata.calculatePercent
|
||||
import com.google.android.material.color.MaterialColors
|
||||
|
||||
class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
||||
|
||||
private var pollOptions: List<PollOptionViewData> = emptyList()
|
||||
private var voteCount: Int = 0
|
||||
private var votersCount: Int? = null
|
||||
private var mode = RESULT
|
||||
private var emojis: List<Emoji> = emptyList()
|
||||
private var resultClickListener: View.OnClickListener? = null
|
||||
private var animateEmojis = false
|
||||
private var enabled = true
|
||||
|
||||
// This can't take [app.pachli.viewdata.PollViewData] as a parameter as it also needs to show
|
||||
// data from polls that have been edited, and the "shape" of that data is quite different (no
|
||||
// information about vote counts, poll IDs, etc).
|
||||
class PollAdapter(
|
||||
val options: List<PollOptionViewData>,
|
||||
private val votesCount: Int,
|
||||
private val votersCount: Int?,
|
||||
val emojis: List<Emoji>,
|
||||
val animateEmojis: Boolean,
|
||||
val displayMode: DisplayMode,
|
||||
/** True if the user can vote in this poll, false otherwise (e.g., it's from an edit) */
|
||||
val enabled: Boolean = true,
|
||||
/** Listener to call when the user clicks on the poll results */
|
||||
private val resultClickListener: View.OnClickListener? = null,
|
||||
/** Listener to call when the user clicks on a poll option */
|
||||
private var optionClickListener: View.OnClickListener? = null
|
||||
private val pollOptionClickListener: View.OnClickListener? = null,
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
||||
|
||||
@JvmOverloads
|
||||
fun setup(
|
||||
options: List<PollOptionViewData>,
|
||||
voteCount: Int,
|
||||
votersCount: Int?,
|
||||
emojis: List<Emoji>,
|
||||
mode: Int,
|
||||
resultClickListener: View.OnClickListener?,
|
||||
animateEmojis: Boolean,
|
||||
enabled: Boolean = true,
|
||||
optionClickListener: View.OnClickListener? = null,
|
||||
) {
|
||||
this.pollOptions = options
|
||||
this.voteCount = voteCount
|
||||
this.votersCount = votersCount
|
||||
this.emojis = emojis
|
||||
this.mode = mode
|
||||
this.resultClickListener = resultClickListener
|
||||
this.animateEmojis = animateEmojis
|
||||
this.enabled = enabled
|
||||
this.optionClickListener = optionClickListener
|
||||
notifyDataSetChanged()
|
||||
/** How to display a poll */
|
||||
enum class DisplayMode {
|
||||
/** Show the results, no voting */
|
||||
RESULT,
|
||||
|
||||
/** Single choice (display as radio buttons) */
|
||||
SINGLE_CHOICE,
|
||||
|
||||
/** Multiple choice (display as check boxes) */
|
||||
MULTIPLE_CHOICE,
|
||||
}
|
||||
|
||||
fun getSelected(): List<Int> {
|
||||
return pollOptions.filter { it.selected }
|
||||
.map { pollOptions.indexOf(it) }
|
||||
}
|
||||
/** @return the indices of the selected options */
|
||||
fun getSelected() = options.withIndex().filter { it.value.selected }.map { it.index }
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> {
|
||||
val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount() = pollOptions.size
|
||||
override fun getItemCount() = options.size
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemPollBinding>, position: Int) {
|
||||
val option = pollOptions[position]
|
||||
val option = options[position]
|
||||
|
||||
val resultTextView = holder.binding.statusPollOptionResult
|
||||
val radioButton = holder.binding.statusPollRadioButton
|
||||
val checkBox = holder.binding.statusPollCheckbox
|
||||
|
||||
resultTextView.visible(mode == RESULT)
|
||||
radioButton.visible(mode == SINGLE)
|
||||
checkBox.visible(mode == MULTIPLE)
|
||||
resultTextView.visible(displayMode == DisplayMode.RESULT)
|
||||
radioButton.visible(displayMode == DisplayMode.SINGLE_CHOICE)
|
||||
checkBox.visible(displayMode == DisplayMode.MULTIPLE_CHOICE)
|
||||
|
||||
// Enable/disable the option widgets as appropriate. Disabling them will also change
|
||||
// the text colour, which is undesirable (this happens when showing status edits) so
|
||||
// reset the text colour as necessary.
|
||||
val defaultTextColor = radioButton.currentTextColor
|
||||
radioButton.isEnabled = enabled
|
||||
checkBox.isEnabled = enabled
|
||||
if (!enabled) {
|
||||
radioButton.setTextColor(defaultTextColor)
|
||||
checkBox.setTextColor(defaultTextColor)
|
||||
}
|
||||
|
||||
when (mode) {
|
||||
RESULT -> {
|
||||
val percent = calculatePercent(option.votesCount, votersCount, voteCount)
|
||||
when (displayMode) {
|
||||
DisplayMode.RESULT -> {
|
||||
val percent = calculatePercent(option.votesCount, votersCount, votesCount)
|
||||
resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context)
|
||||
.emojify(emojis, resultTextView, animateEmojis)
|
||||
|
||||
|
@ -118,31 +116,25 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
resultTextView.setTextColor(textColor)
|
||||
resultTextView.setOnClickListener(resultClickListener)
|
||||
}
|
||||
SINGLE -> {
|
||||
DisplayMode.SINGLE_CHOICE -> {
|
||||
radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis)
|
||||
radioButton.isChecked = option.selected
|
||||
radioButton.setOnClickListener {
|
||||
pollOptions.forEachIndexed { index, pollOption ->
|
||||
options.forEachIndexed { index, pollOption ->
|
||||
pollOption.selected = index == holder.bindingAdapterPosition
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
optionClickListener?.onClick(radioButton)
|
||||
pollOptionClickListener?.onClick(radioButton)
|
||||
}
|
||||
}
|
||||
MULTIPLE -> {
|
||||
DisplayMode.MULTIPLE_CHOICE -> {
|
||||
checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis)
|
||||
checkBox.isChecked = option.selected
|
||||
checkBox.setOnCheckedChangeListener { _, isChecked ->
|
||||
pollOptions[holder.bindingAdapterPosition].selected = isChecked
|
||||
optionClickListener?.onClick(checkBox)
|
||||
options[holder.bindingAdapterPosition].selected = isChecked
|
||||
pollOptionClickListener?.onClick(checkBox)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RESULT = 0
|
||||
const val SINGLE = 1
|
||||
const val MULTIPLE = 2
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,19 +40,23 @@ import app.pachli.util.getFormattedDescription
|
|||
import app.pachli.util.getRelativeTimeSpanString
|
||||
import app.pachli.util.hide
|
||||
import app.pachli.util.loadAvatar
|
||||
import app.pachli.util.makeIcon
|
||||
import app.pachli.util.setClickableMentions
|
||||
import app.pachli.util.setClickableText
|
||||
import app.pachli.util.show
|
||||
import app.pachli.view.MediaPreviewImageView
|
||||
import app.pachli.view.MediaPreviewLayout
|
||||
import app.pachli.view.PollView
|
||||
import app.pachli.view.PreviewCardView
|
||||
import app.pachli.viewdata.PollViewData.Companion.from
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.sparkbutton.SparkButton
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import java.text.NumberFormat
|
||||
import java.util.Date
|
||||
|
||||
|
@ -95,6 +99,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) :
|
|||
private val avatarRadius36dp: Int
|
||||
private val avatarRadius24dp: Int
|
||||
private val mediaPreviewUnloaded: Drawable
|
||||
private val translationProvider: TextView?
|
||||
|
||||
init {
|
||||
context = itemView.context
|
||||
|
@ -144,6 +149,10 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) :
|
|||
moreButton,
|
||||
),
|
||||
)
|
||||
translationProvider = itemView.findViewById<TextView?>(R.id.translationProvider)?.apply {
|
||||
val icon = makeIcon(context, GoogleMaterial.Icon.gmd_translate, textSize.toInt())
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun setDisplayName(
|
||||
|
@ -244,6 +253,24 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) :
|
|||
listener: StatusActionListener,
|
||||
) {
|
||||
val (_, _, _, _, _, _, _, _, _, emojis, _, _, _, _, _, _, _, _, _, _, mentions, tags, _, _, _, poll) = status.actionable
|
||||
when (status.translationState) {
|
||||
TranslationState.SHOW_ORIGINAL -> translationProvider?.hide()
|
||||
TranslationState.TRANSLATING -> {
|
||||
translationProvider?.apply {
|
||||
text = context.getString(R.string.translating)
|
||||
show()
|
||||
}
|
||||
}
|
||||
TranslationState.SHOW_TRANSLATION -> {
|
||||
translationProvider?.apply {
|
||||
status.translation?.provider?.let {
|
||||
text = context.getString(R.string.translation_provider_fmt, it)
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val content = status.content
|
||||
if (expanded) {
|
||||
val emojifiedText =
|
||||
|
@ -254,8 +281,14 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) :
|
|||
}
|
||||
|
||||
poll?.let {
|
||||
val pollViewData = if (status.translationState == TranslationState.SHOW_TRANSLATION) {
|
||||
from(it).copy(translatedPoll = status.translation?.poll)
|
||||
} else {
|
||||
from(it)
|
||||
}
|
||||
|
||||
pollView.bind(
|
||||
from(it),
|
||||
pollViewData,
|
||||
emojis,
|
||||
statusDisplayOptions,
|
||||
numberFormat,
|
||||
|
@ -705,7 +738,13 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) :
|
|||
setReblogged(actionable.reblogged)
|
||||
setFavourited(actionable.favourited)
|
||||
setBookmarked(actionable.bookmarked)
|
||||
val attachments = actionable.attachments
|
||||
val attachments = if (status.translationState == TranslationState.SHOW_TRANSLATION) {
|
||||
status.translation?.attachments?.zip(actionable.attachments) { t, a ->
|
||||
a.copy(description = t.description)
|
||||
} ?: actionable.attachments
|
||||
} else {
|
||||
actionable.attachments
|
||||
}
|
||||
val sensitive = actionable.sensitive
|
||||
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
|
||||
setMediaPreviews(
|
||||
|
|
|
@ -57,7 +57,7 @@ class InstanceInfoRepository @Inject constructor(
|
|||
* Never throws, returns defaults of vanilla Mastodon in case of error.
|
||||
*/
|
||||
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
|
||||
api.getInstance()
|
||||
api.getInstanceV1()
|
||||
.fold(
|
||||
{ instance ->
|
||||
val instanceEntity = InstanceInfoEntity(
|
||||
|
|
|
@ -39,7 +39,7 @@ class LoginWebViewViewModel @Inject constructor(
|
|||
if (this.domain == null) {
|
||||
this.domain = domain
|
||||
viewModelScope.launch {
|
||||
api.getInstance(domain).fold({ instance ->
|
||||
api.getInstanceV1(domain).fold({ instance ->
|
||||
instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty()
|
||||
}, { throwable ->
|
||||
Timber.w("failed to load instance info", throwable)
|
||||
|
|
|
@ -538,8 +538,8 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.more(status, view, position)
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
super.more(statusViewData, view, position)
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
|
|
|
@ -28,17 +28,26 @@ import app.pachli.db.RemoteKeyDao
|
|||
import app.pachli.db.StatusViewDataEntity
|
||||
import app.pachli.db.TimelineDao
|
||||
import app.pachli.db.TimelineStatusWithAccount
|
||||
import app.pachli.db.TranslatedStatusDao
|
||||
import app.pachli.db.TranslatedStatusEntity
|
||||
import app.pachli.di.ApplicationScope
|
||||
import app.pachli.di.TransactionProvider
|
||||
import app.pachli.entity.Translation
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.util.EmptyPagingSource
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
// TODO: This is very similar to NetworkTimelineRepository. They could be merged (and the use
|
||||
// of the cache be made a parameter to getStatusStream), except that they return Pagers of
|
||||
|
@ -48,21 +57,23 @@ import javax.inject.Inject
|
|||
//
|
||||
// Re-writing the caching so that they can use the same types is the TODO.
|
||||
|
||||
@Singleton
|
||||
class CachedTimelineRepository @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val transactionProvider: TransactionProvider,
|
||||
val timelineDao: TimelineDao,
|
||||
private val remoteKeyDao: RemoteKeyDao,
|
||||
private val translatedStatusDao: TranslatedStatusDao,
|
||||
private val gson: Gson,
|
||||
@ApplicationScope private val externalScope: CoroutineScope,
|
||||
) {
|
||||
private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null
|
||||
|
||||
private val activeAccount = accountManager.activeAccount
|
||||
private var activeAccount = accountManager.activeAccount
|
||||
|
||||
/** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
@OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class)
|
||||
fun getStatusStream(
|
||||
kind: TimelineKind,
|
||||
pageSize: Int = PAGE_SIZE,
|
||||
|
@ -70,39 +81,47 @@ class CachedTimelineRepository @Inject constructor(
|
|||
): Flow<PagingData<TimelineStatusWithAccount>> {
|
||||
Timber.d("getStatusStream(): key: $initialKey")
|
||||
|
||||
factory = InvalidatingPagingSourceFactory {
|
||||
activeAccount?.let { timelineDao.getStatuses(it.id) } ?: EmptyPagingSource()
|
||||
}
|
||||
return accountManager.activeAccountFlow.flatMapLatest {
|
||||
activeAccount = it
|
||||
|
||||
val row = initialKey?.let { key ->
|
||||
// Room is row-keyed (by Int), not item-keyed, so the status ID string that was
|
||||
// passed as `initialKey` won't work.
|
||||
//
|
||||
// Instead, get all the status IDs for this account, in timeline order, and find the
|
||||
// row index that contains the status. The row index is the correct initialKey.
|
||||
activeAccount?.let { account ->
|
||||
timelineDao.getStatusRowNumber(account.id)
|
||||
.indexOfFirst { it == key }.takeIf { it != -1 }
|
||||
factory = InvalidatingPagingSourceFactory {
|
||||
activeAccount?.let { timelineDao.getStatuses(it.id) } ?: EmptyPagingSource()
|
||||
}
|
||||
|
||||
val row = initialKey?.let { key ->
|
||||
// Room is row-keyed (by Int), not item-keyed, so the status ID string that was
|
||||
// passed as `initialKey` won't work.
|
||||
//
|
||||
// Instead, get all the status IDs for this account, in timeline order, and find the
|
||||
// row index that contains the status. The row index is the correct initialKey.
|
||||
activeAccount?.let { account ->
|
||||
timelineDao.getStatusRowNumber(account.id)
|
||||
.indexOfFirst { it == key }.takeIf { it != -1 }
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("initialKey: $initialKey is row: $row")
|
||||
|
||||
Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = pageSize,
|
||||
jumpThreshold = PAGE_SIZE * 3,
|
||||
enablePlaceholders = true,
|
||||
),
|
||||
initialKey = row,
|
||||
remoteMediator = CachedTimelineRemoteMediator(
|
||||
initialKey,
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
factory!!,
|
||||
transactionProvider,
|
||||
timelineDao,
|
||||
remoteKeyDao,
|
||||
gson,
|
||||
),
|
||||
pagingSourceFactory = factory!!,
|
||||
).flow
|
||||
}
|
||||
|
||||
Timber.d("initialKey: $initialKey is row: $row")
|
||||
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = pageSize, jumpThreshold = PAGE_SIZE * 3, enablePlaceholders = true),
|
||||
initialKey = row,
|
||||
remoteMediator = CachedTimelineRemoteMediator(
|
||||
initialKey,
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
factory!!,
|
||||
transactionProvider,
|
||||
timelineDao,
|
||||
remoteKeyDao,
|
||||
gson,
|
||||
),
|
||||
pagingSourceFactory = factory!!,
|
||||
).flow
|
||||
}
|
||||
|
||||
/** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */
|
||||
|
@ -124,6 +143,7 @@ class CachedTimelineRepository @Inject constructor(
|
|||
expanded = statusViewData.isExpanded,
|
||||
contentShowing = statusViewData.isShowingContent,
|
||||
contentCollapsed = statusViewData.isCollapsed,
|
||||
translationState = statusViewData.translationState,
|
||||
),
|
||||
)
|
||||
}.join()
|
||||
|
@ -157,9 +177,40 @@ class CachedTimelineRepository @Inject constructor(
|
|||
}.join()
|
||||
|
||||
suspend fun clearAndReloadFromNewest() = externalScope.launch {
|
||||
timelineDao.removeAll(activeAccount!!.id)
|
||||
remoteKeyDao.delete(activeAccount.id)
|
||||
invalidate()
|
||||
activeAccount?.let {
|
||||
timelineDao.removeAll(it.id)
|
||||
remoteKeyDao.delete(it.id)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun translate(statusViewData: StatusViewData): NetworkResult<Translation> {
|
||||
saveStatusViewData(statusViewData.copy(translationState = TranslationState.TRANSLATING))
|
||||
val translation = mastodonApi.translate(statusViewData.actionableId)
|
||||
translation.fold({
|
||||
translatedStatusDao.upsert(
|
||||
TranslatedStatusEntity(
|
||||
serverId = statusViewData.actionableId,
|
||||
timelineUserId = activeAccount!!.id,
|
||||
// TODO: Should this embed the network type instead of copying data
|
||||
// from one type to another?
|
||||
content = it.content,
|
||||
spoilerText = it.spoilerText,
|
||||
poll = it.poll,
|
||||
attachments = it.attachments,
|
||||
provider = it.provider,
|
||||
),
|
||||
)
|
||||
saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION))
|
||||
}, {
|
||||
// Reset the translation state
|
||||
saveStatusViewData(statusViewData)
|
||||
},)
|
||||
return translation
|
||||
}
|
||||
|
||||
suspend fun translateUndo(statusViewData: StatusViewData) {
|
||||
saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -72,6 +72,7 @@ import app.pachli.util.visible
|
|||
import app.pachli.util.withPresentationState
|
||||
import app.pachli.viewdata.AttachmentViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||
|
@ -289,6 +290,7 @@ class TimelineFragment :
|
|||
statusViewData.status.copy(
|
||||
poll = it.action.poll.votedCopy(it.action.choices),
|
||||
)
|
||||
is StatusActionSuccess.Translate -> statusViewData.status
|
||||
}
|
||||
(indexedViewData.value as StatusViewData).status = status
|
||||
|
||||
|
@ -610,8 +612,8 @@ class TimelineFragment :
|
|||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
val status = adapter.peek(position) ?: return
|
||||
super.more(status.status, view, position)
|
||||
val statusViewData = adapter.peek(position) ?: return
|
||||
super.more(statusViewData, view, position)
|
||||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
|
@ -646,13 +648,32 @@ class TimelineFragment :
|
|||
viewModel.changeContentCollapsed(isCollapsed, status)
|
||||
}
|
||||
|
||||
// Can only translate the home timeline at the moment
|
||||
override fun canTranslate() = timelineKind == TimelineKind.Home
|
||||
|
||||
override fun onTranslate(statusViewData: StatusViewData) {
|
||||
viewModel.accept(StatusAction.Translate(statusViewData))
|
||||
}
|
||||
|
||||
override fun onTranslateUndo(statusViewData: StatusViewData) {
|
||||
viewModel.accept(InfallibleUiAction.TranslateUndo(statusViewData))
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
val status = adapter.peek(position) ?: return
|
||||
super.viewMedia(
|
||||
attachmentIndex,
|
||||
AttachmentViewData.list(status.actionable),
|
||||
view,
|
||||
)
|
||||
val statusViewData = adapter.peek(position) ?: return
|
||||
|
||||
// Pass the translated media descriptions through (if appropriate)
|
||||
val actionable = if (statusViewData.translationState == TranslationState.SHOW_TRANSLATION) {
|
||||
statusViewData.actionable.copy(
|
||||
attachments = statusViewData.translation?.attachments?.zip(statusViewData.actionable.attachments) { t, a ->
|
||||
a.copy(description = t.description)
|
||||
} ?: statusViewData.actionable.attachments,
|
||||
)
|
||||
} else {
|
||||
statusViewData.actionable
|
||||
}
|
||||
|
||||
super.viewMedia(attachmentIndex, AttachmentViewData.list(actionable), view)
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
|
|
|
@ -82,18 +82,6 @@ data class UiState(
|
|||
val showFabWhileScrolling: Boolean,
|
||||
)
|
||||
|
||||
/** Preferences the UI reacts to */
|
||||
data class UiPrefs(
|
||||
val showFabWhileScrolling: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
/** Relevant preference keys. Changes to any of these trigger a display update */
|
||||
val prefKeys = setOf(
|
||||
PrefKeys.FAB_HIDE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Ui* classes are copied from NotificationsViewModel. Not yet sure whether these actions
|
||||
// are "global" across all timelines (including notifications) or whether notifications are
|
||||
// sufficiently different to warrant having a duplicate set. Keeping them duplicated for the
|
||||
|
@ -125,6 +113,8 @@ sealed interface InfallibleUiAction : UiAction {
|
|||
// infallible. Reloading the data may fail, but that's handled by the paging system /
|
||||
// adapter refresh logic.
|
||||
data object LoadNewest : InfallibleUiAction
|
||||
|
||||
data class TranslateUndo(val statusViewData: StatusViewData) : InfallibleUiAction
|
||||
}
|
||||
|
||||
sealed interface UiSuccess {
|
||||
|
@ -171,6 +161,9 @@ sealed interface StatusAction : FallibleUiAction {
|
|||
val choices: List<Int>,
|
||||
override val statusViewData: StatusViewData,
|
||||
) : StatusAction
|
||||
|
||||
/** Translate a status */
|
||||
data class Translate(override val statusViewData: StatusViewData) : StatusAction
|
||||
}
|
||||
|
||||
/** Changes to a status' visible state after API calls */
|
||||
|
@ -185,12 +178,15 @@ sealed interface StatusActionSuccess : UiSuccess {
|
|||
|
||||
data class VoteInPoll(override val action: StatusAction.VoteInPoll) : StatusActionSuccess
|
||||
|
||||
data class Translate(override val action: StatusAction.Translate) : StatusActionSuccess
|
||||
|
||||
companion object {
|
||||
fun from(action: StatusAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(action)
|
||||
is StatusAction.Favourite -> Favourite(action)
|
||||
is StatusAction.Reblog -> Reblog(action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(action)
|
||||
is StatusAction.Translate -> Translate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -239,6 +235,12 @@ sealed interface UiError {
|
|||
override val message: Int = R.string.ui_error_vote,
|
||||
) : UiError
|
||||
|
||||
data class TranslateStatus(
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Translate,
|
||||
override val message: Int = R.string.ui_error_translate_status,
|
||||
) : UiError
|
||||
|
||||
data class GetFilters(
|
||||
override val throwable: Throwable,
|
||||
override val action: UiAction? = null,
|
||||
|
@ -251,6 +253,7 @@ sealed interface UiError {
|
|||
is StatusAction.Favourite -> Favourite(throwable, action)
|
||||
is StatusAction.Reblog -> Reblog(throwable, action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
|
||||
is StatusAction.Translate -> TranslateStatus(throwable, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -350,6 +353,9 @@ abstract class TimelineViewModel(
|
|||
action.poll.id,
|
||||
action.choices,
|
||||
)
|
||||
is StatusAction.Translate -> {
|
||||
timelineCases.translate(action.statusViewData)
|
||||
}
|
||||
}.getOrThrow()
|
||||
uiSuccess.emit(StatusActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
|
@ -427,6 +433,13 @@ abstract class TimelineViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
// Undo status translations
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<InfallibleUiAction.TranslateUndo>().collectLatest {
|
||||
timelineCases.translateUndo(it.statusViewData)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.collect { event -> handleEvent(event) }
|
||||
|
|
|
@ -215,7 +215,8 @@ class ViewThreadFragment :
|
|||
lifecycleScope.launch {
|
||||
viewModel.errors.collect { throwable ->
|
||||
Timber.w("failed to load status context", throwable)
|
||||
Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_SHORT)
|
||||
val msg = view.context.getString(R.string.error_generic_fmt, throwable)
|
||||
Snackbar.make(binding.root, msg, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.retry(thisThreadsStatusId)
|
||||
}
|
||||
|
@ -256,6 +257,16 @@ class ViewThreadFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun canTranslate() = true
|
||||
|
||||
override fun onTranslate(statusViewData: StatusViewData) {
|
||||
viewModel.translate(statusViewData)
|
||||
}
|
||||
|
||||
override fun onTranslateUndo(statusViewData: StatusViewData) {
|
||||
viewModel.translateUndo(statusViewData)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.title_view_thread)
|
||||
|
@ -307,7 +318,7 @@ class ViewThreadFragment :
|
|||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
super.more(adapter.currentList[position].status, view, position)
|
||||
super.more(adapter.currentList[position], view, position)
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
|
|
|
@ -35,6 +35,7 @@ import app.pachli.components.timeline.util.ifExpected
|
|||
import app.pachli.db.AccountEntity
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.db.TimelineDao
|
||||
import app.pachli.db.TranslatedStatusEntity
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.network.FilterModel
|
||||
|
@ -42,6 +43,7 @@ import app.pachli.network.MastodonApi
|
|||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.StatusDisplayOptionsRepository
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
|
@ -55,6 +57,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -142,6 +145,8 @@ class ViewThreadViewModel @Inject constructor(
|
|||
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true,
|
||||
isDetailed = true,
|
||||
translationState = timelineStatusWithAccount.viewData?.translationState ?: TranslationState.SHOW_ORIGINAL,
|
||||
translation = timelineStatusWithAccount.translatedStatus,
|
||||
)
|
||||
} else {
|
||||
StatusViewData.from(
|
||||
|
@ -150,6 +155,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
isExpanded = alwaysOpenSpoiler,
|
||||
isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isDetailed = true,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
@ -178,6 +184,8 @@ class ViewThreadViewModel @Inject constructor(
|
|||
isExpanded = detailedStatus.isExpanded,
|
||||
isCollapsed = detailedStatus.isCollapsed,
|
||||
isDetailed = true,
|
||||
translationState = detailedStatus.translationState,
|
||||
translation = detailedStatus.translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -196,6 +204,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
|
||||
isCollapsed = svd?.contentCollapsed ?: true,
|
||||
isDetailed = false,
|
||||
translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
}.filterByFilterAction()
|
||||
val descendants = statusContext.descendants.map {
|
||||
|
@ -207,6 +216,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
|
||||
isCollapsed = svd?.contentCollapsed ?: true,
|
||||
isDetailed = false,
|
||||
translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
}.filterByFilterAction()
|
||||
val statuses = ancestors + detailedStatus + descendants
|
||||
|
@ -438,6 +448,44 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun translate(statusViewData: StatusViewData) {
|
||||
viewModelScope.launch {
|
||||
repository.translate(statusViewData).fold({
|
||||
val translatedEntity = TranslatedStatusEntity(
|
||||
serverId = statusViewData.actionableId,
|
||||
timelineUserId = activeAccount.id,
|
||||
content = it.content,
|
||||
spoilerText = it.spoilerText,
|
||||
poll = it.poll,
|
||||
attachments = it.attachments,
|
||||
provider = it.provider,
|
||||
)
|
||||
updateStatusViewData(statusViewData.status.id) { viewData ->
|
||||
viewData.copy(translation = translatedEntity, translationState = TranslationState.SHOW_TRANSLATION)
|
||||
}
|
||||
}, {
|
||||
// Mastodon returns 403 if it thinks the original status language is the
|
||||
// same as the user's language, ignoring the actual content of the status
|
||||
// (https://github.com/mastodon/documentation/issues/1330). Nothing useful
|
||||
// to do here so swallow the error
|
||||
if (it is HttpException && it.code() == 403) return@fold
|
||||
|
||||
_errors.emit(it)
|
||||
},)
|
||||
}
|
||||
}
|
||||
|
||||
fun translateUndo(statusViewData: StatusViewData) {
|
||||
updateStatusViewData(statusViewData.status.id) { viewData ->
|
||||
viewData.copy(translationState = TranslationState.SHOW_ORIGINAL)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.saveStatusViewData(
|
||||
statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun StatusViewData.getRevealButtonState(): RevealButtonState {
|
||||
val hasWarnings = status.spoilerText.isNotEmpty()
|
||||
|
||||
|
|
|
@ -19,8 +19,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.pachli.R
|
||||
import app.pachli.adapter.PollAdapter
|
||||
import app.pachli.adapter.PollAdapter.Companion.MULTIPLE
|
||||
import app.pachli.adapter.PollAdapter.Companion.SINGLE
|
||||
import app.pachli.adapter.PollAdapter.DisplayMode
|
||||
import app.pachli.databinding.ItemStatusEditBinding
|
||||
import app.pachli.entity.Attachment.Focus
|
||||
import app.pachli.entity.StatusEdit
|
||||
|
@ -133,24 +132,20 @@ class ViewEditsAdapter(
|
|||
// https://github.com/mastodon/mastodon/issues/22571
|
||||
// binding.statusEditPollDescription.show()
|
||||
|
||||
val pollAdapter = PollAdapter()
|
||||
val pollAdapter = PollAdapter(
|
||||
options = edit.poll.options.map { PollOptionViewData.from(it, false) },
|
||||
votesCount = 0,
|
||||
votersCount = null,
|
||||
edit.emojis,
|
||||
animateEmojis = animateEmojis,
|
||||
displayMode = if (edit.poll.multiple) DisplayMode.MULTIPLE_CHOICE else DisplayMode.SINGLE_CHOICE,
|
||||
enabled = false,
|
||||
resultClickListener = null,
|
||||
pollOptionClickListener = null,
|
||||
)
|
||||
|
||||
binding.statusEditPollOptions.adapter = pollAdapter
|
||||
binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
pollAdapter.setup(
|
||||
options = edit.poll.options.map { PollOptionViewData.from(it, false) },
|
||||
voteCount = 0,
|
||||
votersCount = null,
|
||||
emojis = edit.emojis,
|
||||
mode = if (edit.poll.multiple) { // not reported by the api
|
||||
MULTIPLE
|
||||
} else {
|
||||
SINGLE
|
||||
},
|
||||
resultClickListener = null,
|
||||
animateEmojis = animateEmojis,
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
|
||||
if (edit.mediaAttachments.isEmpty()) {
|
||||
|
|
|
@ -35,10 +35,12 @@ import app.pachli.components.conversation.ConversationEntity
|
|||
ConversationEntity::class,
|
||||
RemoteKeyEntity::class,
|
||||
StatusViewDataEntity::class,
|
||||
TranslatedStatusEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class),
|
||||
AutoMigration(from = 2, to = 3),
|
||||
],
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
@ -48,6 +50,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun timelineDao(): TimelineDao
|
||||
abstract fun draftDao(): DraftDao
|
||||
abstract fun remoteKeyDao(): RemoteKeyDao
|
||||
abstract fun translatedStatusDao(): TranslatedStatusDao
|
||||
|
||||
@DeleteColumn("TimelineStatusEntity", "expanded")
|
||||
@DeleteColumn("TimelineStatusEntity", "contentCollapsed")
|
||||
|
|
|
@ -28,6 +28,8 @@ import app.pachli.entity.HashTag
|
|||
import app.pachli.entity.NewPoll
|
||||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TranslatedAttachment
|
||||
import app.pachli.entity.TranslatedPoll
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.net.URLDecoder
|
||||
|
@ -176,4 +178,24 @@ class Converters @Inject constructor(
|
|||
fun jsonToFilterResultList(filterResultListJson: String?): List<FilterResult>? {
|
||||
return gson.fromJson(filterResultListJson, object : TypeToken<List<FilterResult>>() {}.type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun translatedPolltoJson(translatedPoll: TranslatedPoll?): String? {
|
||||
return gson.toJson(translatedPoll)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToTranslatedPoll(translatedPollJson: String?): TranslatedPoll? {
|
||||
return gson.fromJson(translatedPollJson, TranslatedPoll::class.java)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun translatedAttachmentToJson(translatedAttachment: List<TranslatedAttachment>?): String {
|
||||
return gson.toJson(translatedAttachment)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToTranslatedAttachment(translatedAttachmentJson: String): List<TranslatedAttachment>? {
|
||||
return gson.fromJson(translatedAttachmentJson, object : TypeToken<List<TranslatedAttachment>>() {}.type)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,11 +50,15 @@ rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar'
|
|||
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot',
|
||||
svd.serverId as 'svd_serverId', svd.timelineUserId as 'svd_timelineUserId',
|
||||
svd.expanded as 'svd_expanded', svd.contentShowing as 'svd_contentShowing',
|
||||
svd.contentCollapsed as 'svd_contentCollapsed'
|
||||
svd.contentCollapsed as 'svd_contentCollapsed', svd.translationState as 'svd_translationState',
|
||||
t.serverId as 't_serverId', t.timelineUserId as 't_timelineUserId', t.content as 't_content',
|
||||
t.spoilerText as 't_spoilerText', t.poll as 't_poll', t.attachments as 't_attachments',
|
||||
t.provider as 't_provider'
|
||||
FROM TimelineStatusEntity s
|
||||
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId)
|
||||
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId)
|
||||
LEFT JOIN StatusViewDataEntity svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId))
|
||||
LEFT JOIN TranslatedStatusEntity t ON (s.timelineUserId = t.timelineUserId AND (s.serverId = t.serverId OR s.reblogServerId = t.serverId))
|
||||
WHERE s.timelineUserId = :account
|
||||
ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""",
|
||||
)
|
||||
|
@ -92,11 +96,15 @@ rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar'
|
|||
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot',
|
||||
svd.serverId as 'svd_serverId', svd.timelineUserId as 'svd_timelineUserId',
|
||||
svd.expanded as 'svd_expanded', svd.contentShowing as 'svd_contentShowing',
|
||||
svd.contentCollapsed as 'svd_contentCollapsed'
|
||||
svd.contentCollapsed as 'svd_contentCollapsed', svd.translationState as 'svd_translationState',
|
||||
t.serverId as 't_serverId', t.timelineUserId as 't_timelineUserId', t.content as 't_content',
|
||||
t.spoilerText as 't_spoilerText', t.poll as 't_poll', t.attachments as 't_attachments',
|
||||
t.provider as 't_provider'
|
||||
FROM TimelineStatusEntity s
|
||||
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId)
|
||||
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId)
|
||||
LEFT JOIN StatusViewDataEntity svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId))
|
||||
LEFT JOIN TranslatedStatusEntity t ON (s.timelineUserId = t.timelineUserId AND (s.serverId = t.serverId OR s.reblogServerId = t.serverId))
|
||||
WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId)
|
||||
AND s.authorServerId IS NOT NULL""",
|
||||
)
|
||||
|
@ -136,12 +144,20 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId =
|
|||
abstract suspend fun removeAllByUser(accountId: Long, userId: String)
|
||||
|
||||
/**
|
||||
* Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account
|
||||
* Removes everything for one account in the following tables:
|
||||
*
|
||||
* - TimelineStatusEntity
|
||||
* - TimelineAccountEntity
|
||||
* - StatusViewDataEntity
|
||||
* - TranslatedStatusEntity
|
||||
*
|
||||
* @param accountId id of the account for which to clean tables
|
||||
*/
|
||||
suspend fun removeAll(accountId: Long) {
|
||||
removeAllStatuses(accountId)
|
||||
removeAllAccounts(accountId)
|
||||
removeAllStatusViewData(accountId)
|
||||
removeAllTranslatedStatus(accountId)
|
||||
}
|
||||
|
||||
@Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
|
||||
|
@ -153,6 +169,9 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId =
|
|||
@Query("DELETE FROM StatusViewDataEntity WHERE timelineUserId = :accountId")
|
||||
abstract suspend fun removeAllStatusViewData(accountId: Long)
|
||||
|
||||
@Query("DELETE FROM TranslatedStatusEntity WHERE timelineUserId = :accountId")
|
||||
abstract suspend fun removeAllTranslatedStatus(accountId: Long)
|
||||
|
||||
@Query(
|
||||
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
|
||||
AND serverId = :statusId""",
|
||||
|
@ -168,6 +187,7 @@ AND serverId = :statusId""",
|
|||
cleanupStatuses(accountId, limit)
|
||||
cleanupAccounts(accountId)
|
||||
cleanupStatusViewData(accountId, limit)
|
||||
cleanupTranslatedStatus(accountId, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -209,6 +229,21 @@ AND serverId = :statusId""",
|
|||
)
|
||||
abstract suspend fun cleanupStatusViewData(accountId: Long, limit: Int)
|
||||
|
||||
/**
|
||||
* Cleans the TranslatedStatusEntity table of old data, keeping the most recent [limit]
|
||||
* entries.
|
||||
*/
|
||||
@Query(
|
||||
"""DELETE
|
||||
FROM TranslatedStatusEntity
|
||||
WHERE timelineUserId = :accountId
|
||||
AND serverId NOT IN (
|
||||
SELECT serverId FROM TranslatedStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit
|
||||
)
|
||||
""",
|
||||
)
|
||||
abstract suspend fun cleanupTranslatedStatus(accountId: Long, limit: Int)
|
||||
|
||||
@Query(
|
||||
"""UPDATE TimelineStatusEntity SET poll = :poll
|
||||
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""",
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package app.pachli.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
@ -29,6 +30,7 @@ import app.pachli.entity.HashTag
|
|||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TimelineAccount
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.lang.reflect.Type
|
||||
|
@ -193,6 +195,9 @@ data class StatusViewDataEntity(
|
|||
val contentShowing: Boolean,
|
||||
/** Corresponds to [app.pachli.viewdata.StatusViewData.isCollapsed] */
|
||||
val contentCollapsed: Boolean,
|
||||
/** Show the translated version of the status (if it exists) */
|
||||
@ColumnInfo(defaultValue = "SHOW_ORIGINAL")
|
||||
val translationState: TranslationState,
|
||||
)
|
||||
|
||||
val attachmentArrayListType: Type = object : TypeToken<ArrayList<Attachment>>() {}.type
|
||||
|
@ -209,6 +214,8 @@ data class TimelineStatusWithAccount(
|
|||
val reblogAccount: TimelineAccountEntity? = null, // null when no reblog
|
||||
@Embedded(prefix = "svd_")
|
||||
val viewData: StatusViewDataEntity? = null,
|
||||
@Embedded(prefix = "t_")
|
||||
val translatedStatus: TranslatedStatusEntity? = null,
|
||||
) {
|
||||
fun toStatus(gson: Gson): Status {
|
||||
val attachments: ArrayList<Attachment> = gson.fromJson(
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Upsert
|
||||
|
||||
@Dao
|
||||
interface TranslatedStatusDao {
|
||||
@Upsert
|
||||
suspend fun upsert(translatedStatusEntity: TranslatedStatusEntity)
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.db
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.TypeConverters
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TranslatedAttachment
|
||||
import app.pachli.entity.TranslatedPoll
|
||||
|
||||
/**
|
||||
* Translated version of a status, see https://docs.joinmastodon.org/entities/Translation/.
|
||||
*
|
||||
* There is *no* foreignkey relationship between this and [TimelineStatusEntity], as the
|
||||
* translation data is kept even if the status is deleted from the local cache (e.g., during
|
||||
* a refresh operation).
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["serverId", "timelineUserId"],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
data class TranslatedStatusEntity(
|
||||
/** ID of the status as it appeared on the original server */
|
||||
val serverId: String,
|
||||
|
||||
/** Pachli ID for the logged in user, in case there are multiple accounts per instance */
|
||||
val timelineUserId: Long,
|
||||
|
||||
/** The translated text of the status (HTML), equivalent to [Status.content] */
|
||||
val content: String,
|
||||
|
||||
/**
|
||||
* The translated spoiler text of the status (text), if it exists, equivalent to
|
||||
* [Status.spoilerText]
|
||||
*/
|
||||
// Not documented, see https://github.com/mastodon/documentation/issues/1248
|
||||
val spoilerText: String,
|
||||
|
||||
/**
|
||||
* The translated poll (if it exists). Does not contain all the poll data, only the
|
||||
* translated text. Vote counts and other metadata has to be determined from the original
|
||||
* poll object.
|
||||
*/
|
||||
// Not documented, see https://github.com/mastodon/documentation/issues/1248
|
||||
val poll: TranslatedPoll?,
|
||||
|
||||
/**
|
||||
* Translated descriptions for media attachments, if any were attached. Other metadata has
|
||||
* to be determined from the original attachment.
|
||||
*/
|
||||
// Not documented, see https://github.com/mastodon/documentation/issues/1248
|
||||
val attachments: List<TranslatedAttachment>,
|
||||
|
||||
/** The service that provided the machine translation */
|
||||
val provider: String,
|
||||
)
|
|
@ -65,6 +65,9 @@ object DatabaseModule {
|
|||
|
||||
@Provides
|
||||
fun provideRemoteKeyDao(appDatabase: AppDatabase) = appDatabase.remoteKeyDao()
|
||||
|
||||
@Provides
|
||||
fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,7 +18,8 @@ package app.pachli.entity
|
|||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Instance(
|
||||
/** https://docs.joinmastodon.org/entities/V1_Instance/ */
|
||||
data class InstanceV1(
|
||||
val uri: String,
|
||||
// val title: String,
|
||||
// val description: String,
|
||||
|
@ -42,11 +43,11 @@ data class Instance(
|
|||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is Instance) {
|
||||
if (other !is InstanceV1) {
|
||||
return false
|
||||
}
|
||||
val instance = other as Instance?
|
||||
return instance?.uri.equals(uri)
|
||||
val instanceV1 = other as InstanceV1?
|
||||
return instanceV1?.uri.equals(uri)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.entity
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/** https://docs.joinmastodon.org/entities/Translation/ */
|
||||
data class Translation(
|
||||
/** The translated text of the status (HTML), equivalent to [Status.content] */
|
||||
val content: String,
|
||||
|
||||
/**
|
||||
* The language of the source text, as auto-detected by the machine translation
|
||||
* (ISO 639 language code)
|
||||
*/
|
||||
@SerializedName("detected_source_language") val detectedSourceLanguage: String,
|
||||
|
||||
/**
|
||||
* The translated spoiler text of the status (text), if it exists, equivalent to
|
||||
* [Status.spoilerText]
|
||||
*/
|
||||
// Not documented, see https://github.com/mastodon/documentation/issues/1248
|
||||
@SerializedName("spoiler_text") val spoilerText: String,
|
||||
|
||||
/** The translated poll (if it exists) */
|
||||
// Not documented, see https://github.com/mastodon/documentation/issues/1248
|
||||
val poll: TranslatedPoll?,
|
||||
|
||||
/**
|
||||
* Translated descriptions for media attachments, if any were attached. Other metadata has
|
||||
* to be determined from the original attachment.
|
||||
*/
|
||||
// Not documented, see https://github.com/mastodon/documentation/issues/1248
|
||||
@SerializedName("media_attachments") val attachments: List<TranslatedAttachment>,
|
||||
|
||||
/** The service that provided the machine translation */
|
||||
val provider: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* A translated poll. Does not contain all the poll data, only the translated text.
|
||||
* Vote counts and other metadata has to be determined from the original poll object.
|
||||
*/
|
||||
data class TranslatedPoll(
|
||||
val id: String,
|
||||
val options: List<TranslatedPollOption>,
|
||||
)
|
||||
|
||||
/** A translated poll option. */
|
||||
data class TranslatedPollOption(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
/** A translated attachment. Only the description is translated */
|
||||
data class TranslatedAttachment(
|
||||
val id: String,
|
||||
val description: String,
|
||||
)
|
|
@ -25,6 +25,7 @@ import android.content.Intent
|
|||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
|
@ -33,7 +34,9 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import app.pachli.BaseActivity
|
||||
import app.pachli.BottomSheetActivity
|
||||
import app.pachli.PostLookupFallbackBehavior
|
||||
|
@ -50,14 +53,19 @@ import app.pachli.entity.Attachment
|
|||
import app.pachli.entity.Status
|
||||
import app.pachli.interfaces.AccountSelectionListener
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.network.ServerCapabilitiesRepository
|
||||
import app.pachli.network.ServerOperation
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.openLink
|
||||
import app.pachli.util.parseAsMastodonHtml
|
||||
import app.pachli.view.showMuteAccountDialog
|
||||
import app.pachli.viewdata.AttachmentViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
@ -82,6 +90,11 @@ abstract class SFragment : Fragment() {
|
|||
@Inject
|
||||
lateinit var timelineCases: TimelineCases
|
||||
|
||||
@Inject
|
||||
lateinit var serverCapabilitiesRepository: ServerCapabilitiesRepository
|
||||
|
||||
private var serverCanTranslate = false
|
||||
|
||||
override fun startActivity(intent: Intent) {
|
||||
super.startActivity(intent)
|
||||
requireActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
|
||||
|
@ -96,6 +109,21 @@ abstract class SFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
serverCapabilitiesRepository.flow.collect {
|
||||
serverCanTranslate = it.can(
|
||||
ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE,
|
||||
">=1.0".toConstraint(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun openReblog(status: Status?) {
|
||||
if (status == null) return
|
||||
bottomSheetActivity.viewAccount(status.account.id)
|
||||
|
@ -140,8 +168,13 @@ abstract class SFragment : Fragment() {
|
|||
requireActivity().startActivity(intent)
|
||||
}
|
||||
|
||||
protected fun more(status: Status, view: View, position: Int) {
|
||||
val id = status.actionableId
|
||||
/**
|
||||
* Handles the user clicking the "..." (more) button typically at the bottom-right of
|
||||
* the status.
|
||||
*/
|
||||
protected fun more(statusViewData: StatusViewData, view: View, position: Int) {
|
||||
val status = statusViewData.status
|
||||
val actionableId = status.actionableId
|
||||
val accountId = status.actionableStatus.account.id
|
||||
val accountUsername = status.actionableStatus.account.username
|
||||
val statusUrl = status.actionableStatus.url
|
||||
|
@ -170,6 +203,13 @@ abstract class SFragment : Fragment() {
|
|||
} else {
|
||||
popup.inflate(R.menu.status_more)
|
||||
popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty()
|
||||
if (serverCanTranslate && canTranslate() && status.visibility != Status.Visibility.PRIVATE && status.visibility != Status.Visibility.DIRECT) {
|
||||
popup.menu.findItem(R.id.status_translate).isVisible = statusViewData.translationState == TranslationState.SHOW_ORIGINAL
|
||||
popup.menu.findItem(R.id.status_translate_undo).isVisible = statusViewData.translationState == TranslationState.SHOW_TRANSLATION
|
||||
} else {
|
||||
popup.menu.findItem(R.id.status_translate).isVisible = false
|
||||
popup.menu.findItem(R.id.status_translate_undo).isVisible = false
|
||||
}
|
||||
}
|
||||
val menu = popup.menu
|
||||
val openAsItem = menu.findItem(R.id.status_open_as)
|
||||
|
@ -249,7 +289,7 @@ abstract class SFragment : Fragment() {
|
|||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_report -> {
|
||||
openReportPage(accountId, accountUsername, id)
|
||||
openReportPage(accountId, accountUsername, actionableId)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_unreblog_private -> {
|
||||
|
@ -261,15 +301,15 @@ abstract class SFragment : Fragment() {
|
|||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_delete -> {
|
||||
showConfirmDeleteDialog(id, position)
|
||||
showConfirmDeleteDialog(actionableId, position)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_delete_and_redraft -> {
|
||||
showConfirmEditDialog(id, position, status)
|
||||
showConfirmEditDialog(actionableId, position, status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_edit -> {
|
||||
editStatus(id, status)
|
||||
editStatus(actionableId, status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.pin -> {
|
||||
|
@ -288,12 +328,31 @@ abstract class SFragment : Fragment() {
|
|||
}
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_translate -> {
|
||||
onTranslate(statusViewData)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_translate_undo -> {
|
||||
onTranslateUndo(statusViewData)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this class can translate statuses (assuming the server can). Superclasses should
|
||||
* override this if they support translating a status, and also override [onTranslate]
|
||||
* and [onTranslateUndo].
|
||||
*/
|
||||
open fun canTranslate() = false
|
||||
|
||||
open fun onTranslate(statusViewData: StatusViewData) {}
|
||||
|
||||
open fun onTranslateUndo(statusViewData: StatusViewData) {}
|
||||
|
||||
private fun onMute(accountId: String, accountUsername: String) {
|
||||
showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? ->
|
||||
lifecycleScope.launch {
|
||||
|
|
|
@ -28,7 +28,7 @@ import app.pachli.entity.Filter
|
|||
import app.pachli.entity.FilterKeyword
|
||||
import app.pachli.entity.FilterV1
|
||||
import app.pachli.entity.HashTag
|
||||
import app.pachli.entity.Instance
|
||||
import app.pachli.entity.InstanceV1
|
||||
import app.pachli.entity.Marker
|
||||
import app.pachli.entity.MastoList
|
||||
import app.pachli.entity.MediaUploadResult
|
||||
|
@ -44,8 +44,10 @@ import app.pachli.entity.StatusContext
|
|||
import app.pachli.entity.StatusEdit
|
||||
import app.pachli.entity.StatusSource
|
||||
import app.pachli.entity.TimelineAccount
|
||||
import app.pachli.entity.Translation
|
||||
import app.pachli.entity.TrendingTag
|
||||
import app.pachli.entity.TrendsLink
|
||||
import app.pachli.network.model.InstanceV2
|
||||
import app.pachli.util.HttpHeaderLink
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import okhttp3.MultipartBody
|
||||
|
@ -103,7 +105,10 @@ interface MastodonApi {
|
|||
suspend fun getCustomEmojis(): NetworkResult<List<Emoji>>
|
||||
|
||||
@GET("api/v1/instance")
|
||||
suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<Instance>
|
||||
suspend fun getInstanceV1(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<InstanceV1>
|
||||
|
||||
@GET("api/v2/instance")
|
||||
suspend fun getInstanceV2(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<InstanceV2>
|
||||
|
||||
@GET("api/v1/filters")
|
||||
suspend fun getFiltersV1(): NetworkResult<List<FilterV1>>
|
||||
|
@ -311,6 +316,11 @@ interface MastodonApi {
|
|||
@Path("id") statusId: String,
|
||||
): NetworkResult<Status>
|
||||
|
||||
@POST("api/v1/statuses/{id}/translate")
|
||||
suspend fun translate(
|
||||
@Path("id") statusId: String,
|
||||
): NetworkResult<Translation>
|
||||
|
||||
@GET("api/v1/scheduled_statuses")
|
||||
suspend fun scheduledStatuses(
|
||||
@Query("limit") limit: Int? = null,
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.network
|
||||
|
||||
import app.pachli.entity.InstanceV1
|
||||
import app.pachli.network.ServerKind.MASTODON
|
||||
import app.pachli.network.model.InstanceV2
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.binding
|
||||
import com.github.michaelbull.result.getError
|
||||
import com.github.michaelbull.result.getOr
|
||||
import com.github.michaelbull.result.mapError
|
||||
import io.github.z4kn4fein.semver.Version
|
||||
import io.github.z4kn4fein.semver.constraints.Constraint
|
||||
import io.github.z4kn4fein.semver.constraints.satisfiedByAny
|
||||
import kotlin.collections.set
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Identifiers for operations that the server may or may not support.
|
||||
*/
|
||||
enum class ServerOperation(id: String) {
|
||||
// Translate a status, introduced in Mastodon 4.0.0
|
||||
ORG_JOINMASTODON_STATUSES_TRANSLATE("org.joinmastodon.statuses.translate"),
|
||||
}
|
||||
|
||||
enum class ServerKind {
|
||||
MASTODON,
|
||||
AKKOMA,
|
||||
PLEROMA,
|
||||
UNKNOWN,
|
||||
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val rxVersion = """\(compatible; ([^ ]+) ([^)]+)\)""".toRegex()
|
||||
|
||||
fun parse(vs: String): Result<Pair<ServerKind, Version>, ServerCapabilitiesError> = binding {
|
||||
// Parse instance version, which looks like "4.2.1 (compatible; Iceshrimp 2023.11)"
|
||||
// or it's a regular version number.
|
||||
val matchResult = rxVersion.find(vs)
|
||||
if (matchResult == null) {
|
||||
val version = resultOf {
|
||||
Version.parse(vs, strict = false)
|
||||
}.mapError { ServerCapabilitiesError.VersionParse(it) }.bind()
|
||||
return@binding Pair(MASTODON, version)
|
||||
}
|
||||
|
||||
val (software, unparsedVersion) = matchResult.destructured
|
||||
val version = resultOf {
|
||||
Version.parse(unparsedVersion, strict = false)
|
||||
}.mapError { ServerCapabilitiesError.VersionParse(it) }.bind()
|
||||
|
||||
val s = when (software.lowercase()) {
|
||||
"akkoma" -> AKKOMA
|
||||
"mastodon" -> MASTODON
|
||||
"pleroma" -> PLEROMA
|
||||
else -> UNKNOWN
|
||||
}
|
||||
|
||||
return@binding Pair(s, version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Errors that can occur when processing server capabilities */
|
||||
sealed interface ServerCapabilitiesError {
|
||||
val throwable: Throwable
|
||||
|
||||
/** Could not parse the server's version string */
|
||||
data class VersionParse(override val throwable: Throwable) : ServerCapabilitiesError
|
||||
}
|
||||
|
||||
/** Represents operations that can be performed on the given server. */
|
||||
class ServerCapabilities(
|
||||
val serverKind: ServerKind = MASTODON,
|
||||
private val capabilities: Map<ServerOperation, List<Version>> = emptyMap(),
|
||||
) {
|
||||
/**
|
||||
* Returns true if the server supports the given operation at the given minimum version
|
||||
* level, false otherwise.
|
||||
*/
|
||||
fun can(operation: ServerOperation, constraint: Constraint) = capabilities[operation]?.let {
|
||||
versions ->
|
||||
constraint satisfiedByAny versions
|
||||
} ?: false
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Generates [ServerCapabilities] from the instance's configuration report.
|
||||
*/
|
||||
fun from(instance: InstanceV1): Result<ServerCapabilities, ServerCapabilitiesError> = binding {
|
||||
val (serverKind, _) = ServerKind.parse(instance.version).bind()
|
||||
val capabilities = mutableMapOf<ServerOperation, List<Version>>()
|
||||
|
||||
// Create a default set of capabilities (empty). Mastodon servers support InstanceV2 from
|
||||
// v4.0.0 onwards, and there's no information about capabilities for other server kinds.
|
||||
|
||||
ServerCapabilities(serverKind, capabilities)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates [ServerCapabilities] from the instance's configuration report.
|
||||
*/
|
||||
fun from(instance: InstanceV2): Result<ServerCapabilities, ServerCapabilitiesError> = binding {
|
||||
val (serverKind, _) = ServerKind.parse(instance.version).bind()
|
||||
val capabilities = mutableMapOf<ServerOperation, List<Version>>()
|
||||
|
||||
when (serverKind) {
|
||||
MASTODON -> {
|
||||
if (instance.configuration.translation.enabled) {
|
||||
capabilities[ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE] = listOf(Version(major = 1))
|
||||
}
|
||||
}
|
||||
else -> { /* nothing to do yet */ }
|
||||
}
|
||||
|
||||
ServerCapabilities(serverKind, capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See https://www.jacobras.nl/2022/04/resilient-use-cases-with-kotlin-result-coroutines-and-annotations/
|
||||
|
||||
/**
|
||||
* Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable].
|
||||
*
|
||||
* Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814.
|
||||
*/
|
||||
inline fun <R> resultOf(block: () -> R): Result<R, Exception> {
|
||||
return try {
|
||||
Ok(block())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable].
|
||||
*
|
||||
* Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814.
|
||||
*/
|
||||
inline fun <T, R> T.resultOf(block: T.() -> R): Result<R, Exception> {
|
||||
return try {
|
||||
Ok(block())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [mapCatching], but uses [resultOf] instead of [runCatching].
|
||||
*/
|
||||
inline fun <R, T> Result<T, Exception>.mapResult(transform: (value: T) -> R): Result<R, Exception> {
|
||||
val successResult = getOr { null } // getOrNull()
|
||||
return when {
|
||||
successResult != null -> resultOf { transform(successResult) }
|
||||
else -> Err(getError() ?: error("Unreachable state"))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.network
|
||||
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.di.ApplicationScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.getOr
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ServerCapabilitiesRepository @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
@ApplicationScope private val externalScope: CoroutineScope,
|
||||
) {
|
||||
private val _flow = MutableStateFlow(ServerCapabilities())
|
||||
val flow = _flow.asStateFlow()
|
||||
|
||||
init {
|
||||
externalScope.launch {
|
||||
accountManager.activeAccountFlow.collect {
|
||||
_flow.emit(getCapabilities())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the capabilities of the current server. If the capabilties cannot be
|
||||
* determined then a default set of capabilities that all servers are expected
|
||||
* to support is returned.
|
||||
*/
|
||||
private suspend fun getCapabilities(): ServerCapabilities {
|
||||
return mastodonApi.getInstanceV2().fold(
|
||||
{ instance -> ServerCapabilities.from(instance).getOr { null } },
|
||||
{
|
||||
mastodonApi.getInstanceV1().fold({ instance ->
|
||||
ServerCapabilities.from(instance).getOr { null }
|
||||
}, { null },)
|
||||
},
|
||||
) ?: ServerCapabilities()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.network.model
|
||||
|
||||
import app.pachli.entity.Account
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/** https://docs.joinmastodon.org/entities/Instance/ */
|
||||
data class InstanceV2(
|
||||
/** The domain name of the instance */
|
||||
val domain: String,
|
||||
|
||||
/** The title of the website */
|
||||
val title: String,
|
||||
|
||||
/** The version of Mastodon installed on the instance */
|
||||
val version: String,
|
||||
|
||||
/**
|
||||
* The URL for the source code of the software running on this instance, in keeping with AGPL
|
||||
* license requirements.
|
||||
*/
|
||||
@SerializedName("source_url") val sourceUrl: String,
|
||||
|
||||
/** A short, plain-text description defined by the admin. */
|
||||
val description: String,
|
||||
|
||||
/** Usage data for this instance. */
|
||||
val usage: Usage,
|
||||
|
||||
/** An image used to represent this instance. */
|
||||
val thumbnail: Thumbnail,
|
||||
|
||||
/** Primary languages of the website and its staff (ISD 639-1 2 letter language codes) */
|
||||
val languages: List<String>,
|
||||
|
||||
/** Configured values and limits for this website. */
|
||||
val configuration: Configuration,
|
||||
|
||||
/** Information about registering for this website. */
|
||||
val registrations: Registrations,
|
||||
|
||||
/** Hints related to contacting a representative of the website. */
|
||||
val contact: Contact,
|
||||
|
||||
/** An itemized list of rules for this website. */
|
||||
val rules: List<Rule>,
|
||||
)
|
||||
|
||||
data class Usage(
|
||||
/** Usage data related to users on this instance. */
|
||||
val users: Users,
|
||||
)
|
||||
|
||||
data class Users(
|
||||
/** The number of active users in the past 4 weeks. */
|
||||
val activeMonth: Int = 0,
|
||||
)
|
||||
|
||||
data class Thumbnail(
|
||||
/** The URL for the thumbnail image. */
|
||||
val url: String,
|
||||
|
||||
/**
|
||||
* A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails
|
||||
* when media has not been downloaded yet.
|
||||
*/
|
||||
val blurhash: String?,
|
||||
|
||||
/** Links to scaled resolution images, for high DPI screens. */
|
||||
val versions: ThumbnailVersions?,
|
||||
)
|
||||
|
||||
data class ThumbnailVersions(
|
||||
/** The URL for the thumbnail image at 1x resolution. */
|
||||
@SerializedName("@1x") val oneX: String?,
|
||||
|
||||
/** The URL for the thumbnail image at 2x resolution. */
|
||||
@SerializedName("@2x") val twoX: String?,
|
||||
)
|
||||
|
||||
data class Configuration(
|
||||
/** URLs of interest for clients apps. */
|
||||
val urls: InstanceV2Urls,
|
||||
|
||||
/** Limits related to accounts. */
|
||||
val accounts: InstanceV2Accounts,
|
||||
|
||||
/** Limits related to authoring statuses. */
|
||||
val statuses: InstanceV2Statuses,
|
||||
|
||||
/** Hints for which attachments will be accepted. */
|
||||
@SerializedName("media_attachments") val mediaAttachments: MediaAttachments,
|
||||
|
||||
/** Limits related to polls. */
|
||||
val polls: InstanceV2Polls,
|
||||
|
||||
/** Hints related to translation. */
|
||||
val translation: InstanceV2Translation,
|
||||
)
|
||||
|
||||
data class InstanceV2Urls(
|
||||
/** The Websockets URL for connecting to the streaming API. */
|
||||
@SerializedName("streaming_api") val streamingApi: String,
|
||||
)
|
||||
|
||||
data class InstanceV2Accounts(
|
||||
/** The maximum number of featured tags allowed for each account. */
|
||||
@SerializedName("max_featured_tags") val maxFeaturedTags: Int,
|
||||
)
|
||||
|
||||
data class InstanceV2Statuses(
|
||||
/** The maximum number of allowed characters per status. */
|
||||
@SerializedName("max_characters") val maxCharacters: Int,
|
||||
|
||||
/** The maximum number of media attachments that can be added to a status. */
|
||||
@SerializedName("max_media_attachments") val maxMediaAttachments: Int,
|
||||
|
||||
/** Each URL in a status will be assumed to be exactly this many characters. */
|
||||
@SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int,
|
||||
)
|
||||
|
||||
data class MediaAttachments(
|
||||
/** Contains MIME types that can be uploaded. */
|
||||
@SerializedName("supported_mime_types") val supportedMimeTypes: List<String>,
|
||||
|
||||
/** The maximum size of any uploaded image, in bytes. */
|
||||
@SerializedName("image_size_limit") val imageSizeLimit: Int,
|
||||
|
||||
/** The maximum number of pixels (width times height) for image uploads. */
|
||||
@SerializedName("image_matrix_limit") val imageMatrixLimit: Int,
|
||||
|
||||
/** The maximum size of any uploaded video, in bytes. */
|
||||
@SerializedName("video_size_limit") val videoSizeLimit: Int,
|
||||
|
||||
/** The maximum frame rate for any uploaded video. */
|
||||
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int,
|
||||
|
||||
/** The maximum number of pixels (width times height) for video uploads. */
|
||||
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int,
|
||||
)
|
||||
|
||||
data class InstanceV2Polls(
|
||||
/** Each poll is allowed to have up to this many options. */
|
||||
@SerializedName("max_options") val maxOptions: Int,
|
||||
|
||||
/** Each poll option is allowed to have this many characters. */
|
||||
@SerializedName("max_characters_per_option") val maxCharactersPerOption: Int,
|
||||
|
||||
/** The shortest allowed poll duration, in seconds. */
|
||||
@SerializedName("min_expiration") val minExpiration: Int,
|
||||
|
||||
/** The longest allowed poll duration, in seconds. */
|
||||
@SerializedName("max_expiration") val maxExpiration: Int,
|
||||
)
|
||||
|
||||
data class InstanceV2Translation(
|
||||
/** Whether the Translations API is available on this instance. */
|
||||
val enabled: Boolean,
|
||||
)
|
||||
|
||||
data class Registrations(
|
||||
/** Whether registrations are enabled. */
|
||||
val enabled: Boolean,
|
||||
|
||||
/** Whether registrations require moderator approval. */
|
||||
@SerializedName("approval_required") val approvalRequired: Boolean,
|
||||
|
||||
/** A custom message to be shown when registrations are closed. */
|
||||
val message: String?,
|
||||
)
|
||||
|
||||
data class Contact(
|
||||
/** An email address that can be messaged regarding inquiries or issues. */
|
||||
val email: String,
|
||||
|
||||
/** An account that can be contacted natively over the network regarding inquiries or issues. */
|
||||
val account: Account,
|
||||
)
|
||||
|
||||
/** https://docs.joinmastodon.org/entities/Rule/ */
|
||||
data class Rule(
|
||||
/** An identifier for the rule. */
|
||||
val id: String,
|
||||
|
||||
/** The rule to be followed. */
|
||||
val text: String,
|
||||
)
|
|
@ -26,12 +26,15 @@ import app.pachli.appstore.PinEvent
|
|||
import app.pachli.appstore.PollVoteEvent
|
||||
import app.pachli.appstore.ReblogEvent
|
||||
import app.pachli.appstore.StatusDeletedEvent
|
||||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.entity.DeletedStatus
|
||||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Relationship
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.Translation
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.util.getServerErrorMessage
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
|
@ -42,6 +45,7 @@ import javax.inject.Inject
|
|||
class TimelineCases @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val cachedTimelineRepository: CachedTimelineRepository,
|
||||
) {
|
||||
|
||||
suspend fun reblog(statusId: String, reblog: Boolean): NetworkResult<Status> {
|
||||
|
@ -139,6 +143,18 @@ class TimelineCases @Inject constructor(
|
|||
suspend fun rejectFollowRequest(accountId: String): NetworkResult<Relationship> {
|
||||
return mastodonApi.rejectFollowRequest(accountId)
|
||||
}
|
||||
|
||||
suspend fun translate(statusViewData: StatusViewData): NetworkResult<Translation> {
|
||||
return cachedTimelineRepository.translate(statusViewData)
|
||||
}
|
||||
|
||||
suspend fun translateUndo(statusViewData: StatusViewData) {
|
||||
cachedTimelineRepository.translateUndo(statusViewData)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TimelineCases"
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineError(message: String?) : RuntimeException(message)
|
||||
|
|
|
@ -44,4 +44,6 @@ data class StatusDisplayOptions(
|
|||
val showSensitiveMedia: Boolean = false,
|
||||
@get:JvmName("openSpoiler")
|
||||
val openSpoiler: Boolean = false,
|
||||
@get:JvmName("canTranslate")
|
||||
val canTranslate: Boolean = false,
|
||||
)
|
||||
|
|
|
@ -22,8 +22,11 @@ import androidx.annotation.VisibleForTesting.Companion.PRIVATE
|
|||
import app.pachli.db.AccountEntity
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.di.ApplicationScope
|
||||
import app.pachli.network.ServerCapabilitiesRepository
|
||||
import app.pachli.network.ServerOperation
|
||||
import app.pachli.settings.AccountPreferenceDataStore
|
||||
import app.pachli.settings.PrefKeys
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
@ -43,6 +46,7 @@ import javax.inject.Singleton
|
|||
@Singleton
|
||||
class StatusDisplayOptionsRepository @Inject constructor(
|
||||
private val sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
private val serverCapabilitiesRepository: ServerCapabilitiesRepository,
|
||||
private val accountManager: AccountManager,
|
||||
private val accountPreferenceDataStore: AccountPreferenceDataStore,
|
||||
@ApplicationScope private val externalScope: CoroutineScope,
|
||||
|
@ -146,6 +150,17 @@ class StatusDisplayOptionsRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalScope.launch {
|
||||
serverCapabilitiesRepository.flow.collect { serverCapabilities ->
|
||||
Timber.d("Updating because server capabilities changed")
|
||||
_flow.update {
|
||||
it.copy(
|
||||
canTranslate = serverCapabilities.can(ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE, ">=1.0".toConstraint()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = PRIVATE)
|
||||
|
@ -168,6 +183,7 @@ class StatusDisplayOptionsRepository @Inject constructor(
|
|||
showStatsInline = sharedPreferencesRepository.getBoolean(PrefKeys.SHOW_STATS_INLINE, default.showStatsInline),
|
||||
showSensitiveMedia = account?.alwaysShowSensitiveMedia ?: default.showSensitiveMedia,
|
||||
openSpoiler = account?.alwaysOpenSpoiler ?: default.openSpoiler,
|
||||
canTranslate = default.canTranslate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import android.annotation.SuppressLint
|
|||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
@ -81,13 +82,45 @@ class PollView @JvmOverloads constructor(
|
|||
absoluteTimeFormatter: AbsoluteTimeFormatter,
|
||||
listener: OnClickListener,
|
||||
) {
|
||||
val adapter = PollAdapter()
|
||||
val now = System.currentTimeMillis()
|
||||
var displayMode: PollAdapter.DisplayMode = PollAdapter.DisplayMode.RESULT
|
||||
var resultClickListener: View.OnClickListener? = null
|
||||
var pollOptionClickListener: View.OnClickListener? = null
|
||||
|
||||
// Translated? Create new options from old, using the translated title
|
||||
val options = pollViewData.translatedPoll?.let {
|
||||
it.options.zip(pollViewData.options) { t, o ->
|
||||
o.copy(title = t.title)
|
||||
}
|
||||
} ?: pollViewData.options
|
||||
|
||||
val canVote = !(pollViewData.expired(now) || pollViewData.voted)
|
||||
if (canVote) {
|
||||
pollOptionClickListener = View.OnClickListener {
|
||||
binding.statusPollButton.isEnabled = options.firstOrNull { it.selected } != null
|
||||
}
|
||||
displayMode = if (pollViewData.multiple) PollAdapter.DisplayMode.MULTIPLE_CHOICE else PollAdapter.DisplayMode.SINGLE_CHOICE
|
||||
} else {
|
||||
resultClickListener = View.OnClickListener { listener.onClick(null) }
|
||||
binding.statusPollButton.hide()
|
||||
}
|
||||
|
||||
val adapter = PollAdapter(
|
||||
options = options,
|
||||
votesCount = pollViewData.votesCount,
|
||||
votersCount = pollViewData.votersCount,
|
||||
emojis = emojis,
|
||||
animateEmojis = statusDisplayOptions.animateEmojis,
|
||||
displayMode = displayMode,
|
||||
enabled = true,
|
||||
resultClickListener = resultClickListener,
|
||||
pollOptionClickListener = pollOptionClickListener,
|
||||
)
|
||||
|
||||
binding.statusPollOptions.adapter = adapter
|
||||
binding.statusPollOptions.layoutManager = LinearLayoutManager(context)
|
||||
(binding.statusPollOptions.itemAnimator as? DefaultItemAnimator)?.supportsChangeAnimations = false
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
binding.statusPollOptions.show()
|
||||
|
||||
binding.statusPollDescription.text = getPollInfoText(
|
||||
|
@ -99,37 +132,10 @@ class PollView @JvmOverloads constructor(
|
|||
)
|
||||
binding.statusPollDescription.show()
|
||||
|
||||
val expired = pollViewData.expired || ((pollViewData.expiresAt != null) && (now > pollViewData.expiresAt.time))
|
||||
|
||||
// Poll expired or already voted, can't vote now
|
||||
if (expired || pollViewData.voted) {
|
||||
adapter.setup(
|
||||
pollViewData.options,
|
||||
pollViewData.votesCount,
|
||||
pollViewData.votersCount,
|
||||
emojis,
|
||||
PollAdapter.RESULT,
|
||||
{ listener.onClick(null) },
|
||||
statusDisplayOptions.animateEmojis,
|
||||
)
|
||||
binding.statusPollButton.hide()
|
||||
return
|
||||
}
|
||||
|
||||
// Active poll, can vote
|
||||
adapter.setup(
|
||||
pollViewData.options,
|
||||
pollViewData.votesCount,
|
||||
pollViewData.votersCount,
|
||||
emojis,
|
||||
if (pollViewData.multiple) PollAdapter.MULTIPLE else PollAdapter.SINGLE,
|
||||
null,
|
||||
statusDisplayOptions.animateEmojis,
|
||||
true,
|
||||
) {
|
||||
binding.statusPollButton.isEnabled = adapter.getSelected().isNotEmpty()
|
||||
}
|
||||
if (!canVote) return
|
||||
|
||||
// Set up voting
|
||||
binding.statusPollButton.show()
|
||||
binding.statusPollButton.isEnabled = false
|
||||
binding.statusPollButton.setOnClickListener {
|
||||
|
|
|
@ -23,6 +23,7 @@ import androidx.core.text.parseAsHtml
|
|||
import app.pachli.R
|
||||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.PollOption
|
||||
import app.pachli.entity.TranslatedPoll
|
||||
import java.util.Date
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
@ -35,7 +36,15 @@ data class PollViewData(
|
|||
val votersCount: Int?,
|
||||
val options: List<PollOptionViewData>,
|
||||
var voted: Boolean,
|
||||
val translatedPoll: TranslatedPoll?,
|
||||
) {
|
||||
/**
|
||||
* @param timeInMs A timestamp in milliseconds-since-the-epoch
|
||||
* @return true if this poll is either marked as expired, or [timeInMs] is after this poll's
|
||||
* expiry time.
|
||||
*/
|
||||
fun expired(timeInMs: Long) = expired || ((expiresAt != null) && (timeInMs > expiresAt.time))
|
||||
|
||||
companion object {
|
||||
fun from(poll: Poll) = PollViewData(
|
||||
id = poll.id,
|
||||
|
@ -46,6 +55,7 @@ data class PollViewData(
|
|||
votersCount = poll.votersCount,
|
||||
options = poll.options.mapIndexed { index, option -> PollOptionViewData.from(option, poll.ownVotes?.contains(index) == true) },
|
||||
voted = poll.voted,
|
||||
translatedPoll = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,11 @@ package app.pachli.viewdata
|
|||
|
||||
import android.os.Build
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import app.pachli.components.conversation.ConversationAccountEntity
|
||||
import app.pachli.components.conversation.ConversationStatusEntity
|
||||
import app.pachli.db.TimelineStatusWithAccount
|
||||
import app.pachli.db.TranslatedStatusEntity
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Status
|
||||
|
@ -28,11 +30,24 @@ import app.pachli.util.replaceCrashingCharacters
|
|||
import app.pachli.util.shouldTrimStatus
|
||||
import com.google.gson.Gson
|
||||
|
||||
enum class TranslationState {
|
||||
/** Show the original, untranslated status */
|
||||
SHOW_ORIGINAL,
|
||||
|
||||
/** Show the original, untranslated status, but translation is happening */
|
||||
TRANSLATING,
|
||||
|
||||
/** Show the translated status */
|
||||
SHOW_TRANSLATION,
|
||||
}
|
||||
|
||||
/**
|
||||
* Data required to display a status.
|
||||
*/
|
||||
data class StatusViewData(
|
||||
var status: Status,
|
||||
var translation: TranslatedStatusEntity? = null,
|
||||
|
||||
/**
|
||||
* If the status includes a non-empty content warning ([spoilerText]), specifies whether
|
||||
* just the content warning is showing (false), or the whole status content is showing (true).
|
||||
|
@ -67,6 +82,9 @@ data class StatusViewData(
|
|||
// if the Filter.Action class subtypes carried the FilterResult information with them,
|
||||
// and it's impossible to construct them with an empty list.
|
||||
var filterAction: Filter.Action = Filter.Action.NONE,
|
||||
|
||||
/** True if the translated content should be shown (if it exists) */
|
||||
val translationState: TranslationState,
|
||||
) {
|
||||
val id: String
|
||||
get() = status.id
|
||||
|
@ -79,10 +97,20 @@ data class StatusViewData(
|
|||
*/
|
||||
val isCollapsible: Boolean
|
||||
|
||||
private val _content: Spanned
|
||||
|
||||
private val _translatedContent: Spanned
|
||||
|
||||
val content: Spanned
|
||||
get() = if (translationState == TranslationState.SHOW_TRANSLATION) _translatedContent else _content
|
||||
|
||||
private val _spoilerText: String
|
||||
private val _translatedSpoilerText: String
|
||||
|
||||
/** The content warning, may be the empty string */
|
||||
val spoilerText: String
|
||||
get() = if (translationState == TranslationState.SHOW_TRANSLATION) _translatedSpoilerText else _spoilerText
|
||||
|
||||
val username: String
|
||||
|
||||
val actionable: Status
|
||||
|
@ -104,14 +132,22 @@ data class StatusViewData(
|
|||
init {
|
||||
if (Build.VERSION.SDK_INT == 23) {
|
||||
// https://github.com/tuskyapp/Tusky/issues/563
|
||||
this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml())
|
||||
this.spoilerText =
|
||||
this._content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml())
|
||||
this._spoilerText =
|
||||
replaceCrashingCharacters(status.actionableStatus.spoilerText).toString()
|
||||
this.username =
|
||||
replaceCrashingCharacters(status.actionableStatus.account.username).toString()
|
||||
this._translatedContent = translation?.content?.let {
|
||||
replaceCrashingCharacters(it.parseAsMastodonHtml())
|
||||
} ?: SpannedString("")
|
||||
this._translatedSpoilerText = translation?.spoilerText?.let {
|
||||
replaceCrashingCharacters(it).toString()
|
||||
} ?: ""
|
||||
} else {
|
||||
this.content = status.actionableStatus.content.parseAsMastodonHtml()
|
||||
this.spoilerText = status.actionableStatus.spoilerText
|
||||
this._content = status.actionableStatus.content.parseAsMastodonHtml()
|
||||
this._translatedContent = translation?.content?.parseAsMastodonHtml() ?: SpannedString("")
|
||||
this._spoilerText = status.actionableStatus.spoilerText
|
||||
this._translatedSpoilerText = translation?.spoilerText ?: ""
|
||||
this.username = status.actionableStatus.account.username
|
||||
}
|
||||
this.isCollapsible = shouldTrimStatus(this.content)
|
||||
|
@ -163,6 +199,8 @@ data class StatusViewData(
|
|||
isCollapsed: Boolean,
|
||||
isDetailed: Boolean = false,
|
||||
filterAction: Filter.Action = Filter.Action.NONE,
|
||||
translationState: TranslationState = TranslationState.SHOW_ORIGINAL,
|
||||
translation: TranslatedStatusEntity? = null,
|
||||
) = StatusViewData(
|
||||
status = status,
|
||||
isShowingContent = isShowingContent,
|
||||
|
@ -170,6 +208,8 @@ data class StatusViewData(
|
|||
isExpanded = isExpanded,
|
||||
isDetailed = isDetailed,
|
||||
filterAction = filterAction,
|
||||
translationState = translationState,
|
||||
translation = translation,
|
||||
)
|
||||
|
||||
fun from(conversationStatusEntity: ConversationStatusEntity) = StatusViewData(
|
||||
|
@ -207,6 +247,7 @@ data class StatusViewData(
|
|||
isExpanded = conversationStatusEntity.expanded,
|
||||
isShowingContent = conversationStatusEntity.showingHiddenContent,
|
||||
isCollapsed = conversationStatusEntity.collapsed,
|
||||
translationState = TranslationState.SHOW_ORIGINAL, // TODO: Include this in conversationStatusEntity
|
||||
)
|
||||
|
||||
fun from(
|
||||
|
@ -215,14 +256,17 @@ data class StatusViewData(
|
|||
isExpanded: Boolean,
|
||||
isShowingContent: Boolean,
|
||||
isDetailed: Boolean = false,
|
||||
translationState: TranslationState = TranslationState.SHOW_ORIGINAL,
|
||||
): StatusViewData {
|
||||
val status = timelineStatusWithAccount.toStatus(gson)
|
||||
return StatusViewData(
|
||||
status = status,
|
||||
translation = timelineStatusWithAccount.translatedStatus,
|
||||
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded,
|
||||
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (isShowingContent || !status.actionableStatus.sensitive),
|
||||
isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true,
|
||||
isDetailed = isDetailed,
|
||||
translationState = timelineStatusWithAccount.viewData?.translationState ?: translationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -215,6 +215,20 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
|
||||
tools:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/translationProvider"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="14dp"
|
||||
android:textSize="?attr/status_text_small"
|
||||
android:drawablePadding="4dp"
|
||||
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
||||
app:layout_constraintTop_toBottomOf="@id/status_poll"
|
||||
tools:text="Translation provider: DeepL"
|
||||
tools:ignore="SelectableText"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/status_reply"
|
||||
style="@style/AppImageButton"
|
||||
|
@ -227,7 +241,7 @@
|
|||
app:layout_constraintEnd_toStartOf="@id/status_inset"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
||||
app:layout_constraintTop_toBottomOf="@id/status_poll"
|
||||
app:layout_constraintTop_toBottomOf="@id/translationProvider"
|
||||
app:srcCompat="@drawable/ic_reply_24dp" />
|
||||
|
||||
<TextView
|
||||
|
|
|
@ -177,6 +177,20 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/translationProvider"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="14dp"
|
||||
android:textSize="?attr/status_text_small"
|
||||
android:drawablePadding="4dp"
|
||||
app:layout_constraintStart_toStartOf="@id/status_poll"
|
||||
app:layout_constraintTop_toBottomOf="@id/status_poll"
|
||||
tools:text="Translation provider: DeepL"
|
||||
tools:ignore="SelectableText"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_meta_info"
|
||||
android:layout_width="0dp"
|
||||
|
@ -191,7 +205,7 @@
|
|||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/status_poll"
|
||||
app:layout_constraintTop_toBottomOf="@id/translationProvider"
|
||||
tools:text="21 Dec 2018 18:45" />
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
|
|
|
@ -33,4 +33,10 @@
|
|||
<item
|
||||
android:id="@+id/status_report"
|
||||
android:title="@string/action_report" />
|
||||
<item
|
||||
android:id="@+id/status_translate"
|
||||
android:title="@string/action_translate" />
|
||||
<item
|
||||
android:id="@+id/status_translate_undo"
|
||||
android:title="@string/action_translate_undo" />
|
||||
</menu>
|
||||
|
|
|
@ -206,6 +206,8 @@
|
|||
<string name="action_dismiss">Dismiss</string>
|
||||
<string name="action_details">Details</string>
|
||||
<string name="action_add_reaction">add reaction</string>
|
||||
<string name="action_translate">Translate</string>
|
||||
<string name="action_translate_undo">Undo translate</string>
|
||||
|
||||
<string name="title_hashtags_dialog">Hashtags</string>
|
||||
<string name="title_mentions_dialog">Mentions</string>
|
||||
|
@ -793,6 +795,7 @@
|
|||
<string name="ui_error_favourite">Favoriting post failed: %s</string>
|
||||
<string name="ui_error_reblog">Boosting post failed: %s</string>
|
||||
<string name="ui_error_vote">Voting in poll failed: %s</string>
|
||||
<string name="ui_error_translate_status">Translation 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_filter_v1_load">Loading filters failed: %s</string>
|
||||
|
@ -828,7 +831,6 @@
|
|||
<string name="dialog_delete_filter_text">Delete filter \'%1$s\'?"</string>
|
||||
<string name="dialog_delete_filter_positive_action">Delete</string>
|
||||
<string name="dialog_save_profile_changes_message">Do you want to save your profile changes?</string>
|
||||
|
||||
<string name="reaction_name_and_count">%1$s %2$d</string>
|
||||
|
||||
<string name="pref_title_update_settings">Software updates</string>
|
||||
|
@ -839,4 +841,7 @@
|
|||
<string name="update_dialog_title">An update is available</string>
|
||||
<string name="update_dialog_neutral">Don\'t remind me for this version</string>
|
||||
<string name="update_dialog_negative">Never remind me</string>
|
||||
|
||||
<string name="translating">Translating…</string>
|
||||
<string name="translation_provider_fmt">%1$s</string>
|
||||
</resources>
|
||||
|
|
|
@ -3,6 +3,7 @@ package app.pachli
|
|||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
|
@ -44,12 +45,14 @@ class StatusComparisonTest {
|
|||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
val viewdata2 = StatusViewData(
|
||||
status = createStatus(),
|
||||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
assertEquals(viewdata1, viewdata2)
|
||||
}
|
||||
|
@ -61,12 +64,14 @@ class StatusComparisonTest {
|
|||
isExpanded = true,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
val viewdata2 = StatusViewData(
|
||||
status = createStatus(),
|
||||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
assertNotEquals(viewdata1, viewdata2)
|
||||
}
|
||||
|
@ -78,12 +83,14 @@ class StatusComparisonTest {
|
|||
isExpanded = true,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
val viewdata2 = StatusViewData(
|
||||
status = createStatus(),
|
||||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
assertNotEquals(viewdata1, viewdata2)
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ import app.pachli.R
|
|||
import app.pachli.components.instanceinfo.InstanceInfoRepository
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.entity.Account
|
||||
import app.pachli.entity.Instance
|
||||
import app.pachli.entity.InstanceConfiguration
|
||||
import app.pachli.entity.InstanceV1
|
||||
import app.pachli.entity.StatusConfiguration
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.rules.lazyActivityScenarioRule
|
||||
|
@ -69,7 +69,7 @@ class ComposeActivityTest {
|
|||
launchActivity = false,
|
||||
)
|
||||
|
||||
private var getInstanceCallback: (() -> Instance)? = null
|
||||
private var getInstanceCallback: (() -> InstanceV1)? = null
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
@ -85,7 +85,7 @@ class ComposeActivityTest {
|
|||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList())
|
||||
onBlocking { getInstance() } doAnswer {
|
||||
onBlocking { getInstanceV1() } doAnswer {
|
||||
getInstanceCallback?.invoke().let { instance ->
|
||||
if (instance == null) {
|
||||
NetworkResult.failure(Throwable())
|
||||
|
@ -555,8 +555,8 @@ class ComposeActivityTest {
|
|||
activity.findViewById<EditText>(R.id.composeEditField).setText(text ?: "Some text")
|
||||
}
|
||||
|
||||
private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance {
|
||||
return Instance(
|
||||
private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): InstanceV1 {
|
||||
return InstanceV1(
|
||||
uri = "https://example.token",
|
||||
version = "2.6.3",
|
||||
maxTootChars = maximumLegacyTootCharacters,
|
||||
|
|
|
@ -20,8 +20,8 @@ package app.pachli.components.instanceinfo
|
|||
import app.pachli.db.AccountEntity
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.db.InstanceDao
|
||||
import app.pachli.entity.Instance
|
||||
import app.pachli.entity.InstanceConfiguration
|
||||
import app.pachli.entity.InstanceV1
|
||||
import app.pachli.entity.StatusConfiguration
|
||||
import app.pachli.network.MastodonApi
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
|
@ -32,7 +32,7 @@ import org.mockito.kotlin.doReturn
|
|||
import org.mockito.kotlin.mock
|
||||
|
||||
class InstanceInfoRepositoryTest {
|
||||
private var instanceResponseCallback: (() -> Instance)? = null
|
||||
private var instanceResponseCallback: (() -> InstanceV1)? = null
|
||||
|
||||
private var accountManager: AccountManager = mock {
|
||||
on { activeAccount } doReturn AccountEntity(
|
||||
|
@ -61,7 +61,7 @@ class InstanceInfoRepositoryTest {
|
|||
private fun setup() {
|
||||
mastodonApi = mock {
|
||||
onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList())
|
||||
onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance ->
|
||||
onBlocking { getInstanceV1() } doReturn instanceResponseCallback?.invoke().let { instance ->
|
||||
if (instance == null) {
|
||||
NetworkResult.failure(Throwable())
|
||||
} else {
|
||||
|
@ -121,8 +121,8 @@ class InstanceInfoRepositoryTest {
|
|||
assertEquals(customMaximum * 2, instanceInfo.maxChars)
|
||||
}
|
||||
|
||||
private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance {
|
||||
return Instance(
|
||||
private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): InstanceV1 {
|
||||
return InstanceV1(
|
||||
uri = "https://example.token",
|
||||
version = "2.6.3",
|
||||
maxTootChars = maximumLegacyTootCharacters,
|
||||
|
|
|
@ -25,6 +25,8 @@ import app.pachli.components.timeline.MainCoroutineRule
|
|||
import app.pachli.db.AccountEntity
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.fakes.InMemorySharedPreferences
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.network.ServerCapabilitiesRepository
|
||||
import app.pachli.settings.AccountPreferenceDataStore
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.SharedPreferencesRepository
|
||||
|
@ -36,6 +38,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody
|
|||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
|
@ -104,8 +107,20 @@ abstract class NotificationsViewModelTestBase {
|
|||
TestScope(),
|
||||
)
|
||||
|
||||
val mastodonApi: MastodonApi = mock {
|
||||
onBlocking { getInstanceV2() } doAnswer { null }
|
||||
onBlocking { getInstanceV1() } doAnswer { null }
|
||||
}
|
||||
|
||||
val serverCapabilitiesRepository = ServerCapabilitiesRepository(
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
statusDisplayOptionsRepository = StatusDisplayOptionsRepository(
|
||||
sharedPreferencesRepository,
|
||||
serverCapabilitiesRepository,
|
||||
accountManager,
|
||||
accountPreferenceDataStore,
|
||||
TestScope(),
|
||||
|
|
|
@ -20,6 +20,7 @@ package app.pachli.components.notifications
|
|||
import app.cash.turbine.test
|
||||
import app.pachli.FilterV1Test.Companion.mockStatus
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
@ -46,6 +47,7 @@ class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase()
|
|||
isExpanded = true,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
|
||||
/** Action to bookmark a status */
|
||||
|
|
|
@ -26,6 +26,7 @@ import app.pachli.components.timeline.viewmodel.TimelineViewModel
|
|||
import app.pachli.db.AccountManager
|
||||
import app.pachli.entity.Account
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.network.ServerCapabilitiesRepository
|
||||
import app.pachli.settings.AccountPreferenceDataStore
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.SharedPreferencesRepository
|
||||
|
@ -131,8 +132,15 @@ abstract class CachedTimelineViewModelTestBase {
|
|||
|
||||
timelineCases = mock()
|
||||
|
||||
val serverCapabilitiesRepository = ServerCapabilitiesRepository(
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
statusDisplayOptionsRepository = StatusDisplayOptionsRepository(
|
||||
sharedPreferencesRepository,
|
||||
serverCapabilitiesRepository,
|
||||
accountManager,
|
||||
accountPreferenceDataStore,
|
||||
TestScope(),
|
||||
|
|
|
@ -23,6 +23,7 @@ import app.pachli.components.timeline.viewmodel.StatusAction
|
|||
import app.pachli.components.timeline.viewmodel.StatusActionSuccess
|
||||
import app.pachli.components.timeline.viewmodel.UiError
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
|
@ -53,6 +54,7 @@ class CachedTimelineViewModelTestStatusAction : CachedTimelineViewModelTestBase(
|
|||
isExpanded = true,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
|
||||
/** Action to bookmark a status */
|
||||
|
|
|
@ -25,6 +25,7 @@ import app.pachli.components.timeline.viewmodel.TimelineViewModel
|
|||
import app.pachli.db.AccountManager
|
||||
import app.pachli.entity.Account
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.network.ServerCapabilitiesRepository
|
||||
import app.pachli.settings.AccountPreferenceDataStore
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.SharedPreferencesRepository
|
||||
|
@ -123,8 +124,15 @@ abstract class NetworkTimelineViewModelTestBase {
|
|||
|
||||
timelineCases = mock()
|
||||
|
||||
val serverCapabilitiesRepository = ServerCapabilitiesRepository(
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
statusDisplayOptionsRepository = StatusDisplayOptionsRepository(
|
||||
sharedPreferencesRepository,
|
||||
serverCapabilitiesRepository,
|
||||
accountManager,
|
||||
accountPreferenceDataStore,
|
||||
TestScope(),
|
||||
|
|
|
@ -23,6 +23,7 @@ import app.pachli.components.timeline.viewmodel.StatusAction
|
|||
import app.pachli.components.timeline.viewmodel.StatusActionSuccess
|
||||
import app.pachli.components.timeline.viewmodel.UiError
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
|
@ -53,6 +54,7 @@ class NetworkTimelineViewModelTestStatusAction : NetworkTimelineViewModelTestBas
|
|||
isExpanded = true,
|
||||
isShowingContent = false,
|
||||
isCollapsed = false,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
|
||||
/** Action to bookmark a status */
|
||||
|
|
|
@ -7,6 +7,7 @@ import app.pachli.db.TimelineStatusWithAccount
|
|||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TimelineAccount
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TranslationState
|
||||
import com.google.gson.Gson
|
||||
import java.util.Date
|
||||
|
||||
|
@ -86,6 +87,7 @@ fun mockStatusViewData(
|
|||
isShowingContent = isShowingContent,
|
||||
isCollapsed = isCollapsed,
|
||||
isDetailed = isDetailed,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
)
|
||||
|
||||
fun mockStatusEntityWithAccount(
|
||||
|
@ -113,6 +115,7 @@ fun mockStatusEntityWithAccount(
|
|||
expanded = expanded,
|
||||
contentShowing = false,
|
||||
contentCollapsed = true,
|
||||
translationState = TranslationState.SHOW_ORIGINAL,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import app.pachli.db.TimelineDao
|
|||
import app.pachli.entity.Account
|
||||
import app.pachli.entity.StatusContext
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.network.ServerCapabilitiesRepository
|
||||
import app.pachli.settings.AccountPreferenceDataStore
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.SharedPreferencesRepository
|
||||
|
@ -166,8 +167,15 @@ class ViewThreadViewModelTest {
|
|||
onBlocking { getStatusViewData(any()) } doReturn emptyMap()
|
||||
}
|
||||
|
||||
val serverCapabilitiesRepository = ServerCapabilitiesRepository(
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
statusDisplayOptionsRepository = StatusDisplayOptionsRepository(
|
||||
sharedPreferencesRepository,
|
||||
serverCapabilitiesRepository,
|
||||
accountManager,
|
||||
accountPreferenceDataStore,
|
||||
TestScope(),
|
||||
|
|
|
@ -65,4 +65,7 @@ object FakeDatabaseModule {
|
|||
|
||||
@Provides
|
||||
fun provideRemoteKeyDao(appDatabase: AppDatabase) = appDatabase.remoteKeyDao()
|
||||
|
||||
@Provides
|
||||
fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao()
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import org.junit.Test
|
|||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.mock
|
||||
|
||||
class InstanceSwitchAuthInterceptorTest {
|
||||
class InstanceV1SwitchAuthInterceptorTest {
|
||||
|
||||
private val mockWebServer = MockWebServer()
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.network
|
||||
|
||||
import app.pachli.network.ServerKind.AKKOMA
|
||||
import app.pachli.network.ServerKind.MASTODON
|
||||
import app.pachli.network.ServerKind.PLEROMA
|
||||
import app.pachli.network.ServerKind.UNKNOWN
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import io.github.z4kn4fein.semver.Version
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
import org.junit.runners.Parameterized.Parameters
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class ServerKindTest(
|
||||
private val input: String,
|
||||
private val want: Result<Pair<ServerKind, Version>, ServerCapabilitiesError>,
|
||||
) {
|
||||
companion object {
|
||||
@Parameters(name = "{0}")
|
||||
@JvmStatic
|
||||
fun data(): Iterable<Any> {
|
||||
return listOf(
|
||||
arrayOf(
|
||||
"4.0.0",
|
||||
Ok(Pair(MASTODON, Version.parse("4.0.0", strict = false))),
|
||||
),
|
||||
arrayOf(
|
||||
"4.2.1 (compatible; Iceshrimp 2023.11)",
|
||||
Ok(Pair(UNKNOWN, Version.parse("2023.11", strict = false))),
|
||||
),
|
||||
arrayOf(
|
||||
"2.7.2 (compatible; Akkoma 3.10.3-202-g1b838627-1-CI-COMMIT-TAG---)",
|
||||
Ok(Pair(AKKOMA, Version.parse("3.10.3-202-g1b838627-1-CI-COMMIT-TAG---", strict = false))),
|
||||
),
|
||||
arrayOf(
|
||||
"2.7.2 (compatible; Pleroma 2.5.54-640-gacbec640.develop+soapbox)",
|
||||
Ok(Pair(PLEROMA, Version.parse("2.5.54-640-gacbec640.develop+soapbox", strict = false))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerKind parse works`() {
|
||||
assertEquals(want, ServerKind.parse(input))
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||
import app.cash.turbine.test
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.PinEvent
|
||||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.network.MastodonApi
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
|
@ -25,6 +26,7 @@ class TimelineCasesTest {
|
|||
|
||||
private lateinit var api: MastodonApi
|
||||
private lateinit var eventHub: EventHub
|
||||
private lateinit var cachedTimelineRepository: CachedTimelineRepository
|
||||
private lateinit var timelineCases: TimelineCases
|
||||
|
||||
private val statusId = "1234"
|
||||
|
@ -33,7 +35,8 @@ class TimelineCasesTest {
|
|||
fun setup() {
|
||||
api = mock()
|
||||
eventHub = EventHub()
|
||||
timelineCases = TimelineCases(api, eventHub)
|
||||
cachedTimelineRepository = mock()
|
||||
timelineCases = TimelineCases(api, eventHub, cachedTimelineRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -25,6 +25,8 @@ import app.pachli.components.compose.HiltTestApplication_Application
|
|||
import app.pachli.components.timeline.MainCoroutineRule
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.entity.Account
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.network.ServerCapabilitiesRepository
|
||||
import app.pachli.settings.AccountPreferenceDataStore
|
||||
import app.pachli.settings.PrefKeys
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
@ -63,6 +65,9 @@ class StatusDisplayOptionsRepositoryTest {
|
|||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var sharedPreferencesRepository: SharedPreferencesRepository
|
||||
|
||||
|
@ -102,8 +107,15 @@ class StatusDisplayOptionsRepositoryTest {
|
|||
TestScope(),
|
||||
)
|
||||
|
||||
val serverCapabilitiesRepository = ServerCapabilitiesRepository(
|
||||
mastodonApi,
|
||||
accountManager,
|
||||
TestScope(),
|
||||
)
|
||||
|
||||
statusDisplayOptionsRepository = StatusDisplayOptionsRepository(
|
||||
sharedPreferencesRepository,
|
||||
serverCapabilitiesRepository,
|
||||
accountManager,
|
||||
accountPreferenceDataStore,
|
||||
TestScope(),
|
||||
|
|
|
@ -38,6 +38,7 @@ glide-animation-plugin = "2.23.0"
|
|||
gson = "2.10.1"
|
||||
hilt = "2.48.1"
|
||||
kotlin = "1.9.20"
|
||||
kotlin-result = "1.1.8"
|
||||
image-cropper = "4.3.2"
|
||||
material = "1.9.0"
|
||||
material-drawer = "8.4.5"
|
||||
|
@ -51,6 +52,7 @@ robolectric = "4.10.3"
|
|||
rxandroid3 = "3.0.2"
|
||||
rxjava3 = "3.1.7"
|
||||
rxkotlin3 = "3.0.1"
|
||||
semver = "1.4.2"
|
||||
sparkbutton = "4.1.0"
|
||||
timber = "5.0.1"
|
||||
touchimageview = "3.6"
|
||||
|
@ -129,6 +131,7 @@ gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
|||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
|
||||
kotlin-result = { module = "com.michael-bull.kotlin-result:kotlin-result", version.ref = "kotlin-result" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
|
@ -148,6 +151,7 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr
|
|||
rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid3" }
|
||||
rxjava3-core = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava3" }
|
||||
rxjava3-kotlin = { module = "io.reactivex.rxjava3:rxkotlin", version.ref = "rxkotlin3" }
|
||||
semver = { module = "io.github.z4kn4fein:semver", version.ref = "semver" }
|
||||
sparkbutton = { module = "com.github.connyduck:sparkbutton", version.ref = "sparkbutton" }
|
||||
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||
touchimageview = { module = "com.github.MikeOrtiz:TouchImageView", version.ref = "touchimageview" }
|
||||
|
|
Loading…
Reference in New Issue