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:
Nik Clayton 2023-11-12 19:51:46 +01:00 committed by GitHub
parent 27367d94bd
commit d40b87f0a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2843 additions and 550 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,6 +65,9 @@ object DatabaseModule {
@Provides
fun provideRemoteKeyDao(appDatabase: AppDatabase) = appDatabase.remoteKeyDao()
@Provides
fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao()
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,4 +65,7 @@ object FakeDatabaseModule {
@Provides
fun provideRemoteKeyDao(appDatabase: AppDatabase) = appDatabase.remoteKeyDao()
@Provides
fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao()
}

View File

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

View File

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

View File

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

View File

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

View File

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