From c8aa4fd374ba4171103f96352d469a5f4c8f8d9a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 18 Oct 2024 15:37:10 +0200 Subject: [PATCH] fix: Disable "Scheduled post" support on GoToSocial accounts (#1025) GoToSocial servers don't support scheduled posts; they return the wrong type, and this can cause a loop of posting. The GoToSocial bug to implement scheduled posts is https://github.com/superseriousbusiness/gotosocial/issues/1006. Fix this by adding a new server capability for scheduled post support, using it for most servers, and disabling it for GoToSocial. If scheduled post support is not available for an account: - The "Scheduled posts" menu option is not shown. - The scheduling button (clock) when composing a post is hidden, so the user cannot set scheduling parameters. Fixes #963 --- app/src/main/java/app/pachli/MainActivity.kt | 44 +++++++++++++++---- .../components/compose/ComposeActivity.kt | 9 ++++ .../components/compose/ComposeViewModel.kt | 13 ++++++ .../app/pachli/core/model/ServerOperation.kt | 9 ++++ .../kotlin/app/pachli/core/network/Server.kt | 28 ++++++++++-- .../app/pachli/core/network/ServerTest.kt | 15 +++++-- 6 files changed, 101 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/app/pachli/MainActivity.kt b/app/src/main/java/app/pachli/MainActivity.kt index c439416a1..3d9539fa6 100644 --- a/app/src/main/java/app/pachli/MainActivity.kt +++ b/app/src/main/java/app/pachli/MainActivity.kt @@ -81,9 +81,11 @@ import app.pachli.core.common.util.unsafeLazy import app.pachli.core.data.repository.Lists import app.pachli.core.data.repository.ListsRepository import app.pachli.core.data.repository.ListsRepository.Companion.compareByListTitle +import app.pachli.core.data.repository.ServerRepository import app.pachli.core.database.model.AccountEntity import app.pachli.core.designsystem.EmbeddedFontFamily import app.pachli.core.designsystem.R as DR +import app.pachli.core.model.ServerOperation import app.pachli.core.model.Timeline import app.pachli.core.navigation.AboutActivityIntent import app.pachli.core.navigation.AccountActivityIntent @@ -160,12 +162,15 @@ import com.mikepenz.materialdrawer.model.interfaces.nameRes import com.mikepenz.materialdrawer.model.interfaces.nameText import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader import com.mikepenz.materialdrawer.util.DrawerImageLoader +import com.mikepenz.materialdrawer.util.addItemAtPosition import com.mikepenz.materialdrawer.util.addItems import com.mikepenz.materialdrawer.util.addItemsAtPosition +import com.mikepenz.materialdrawer.util.getPosition import com.mikepenz.materialdrawer.util.updateBadge import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.hilt.android.AndroidEntryPoint import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE +import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import kotlin.math.max import kotlinx.coroutines.CoroutineScope @@ -205,6 +210,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { @Inject lateinit var androidNotificationsAreEnabled: AndroidNotificationsAreEnabledUseCase + @Inject + lateinit var serverRepository: ServerRepository + private val binding by viewBinding(ActivityMainBinding::inflate) override val actionButton by unsafeLazy { binding.composeButton } @@ -402,6 +410,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } + lifecycleScope.launch { + serverRepository.flow.collect { + refreshMainDrawerItems(intent.pachliAccountId, addSearchButton = hideTopToolbar) + } + } + selectedEmojiPack = sharedPreferencesRepository.getString(EMOJI_PREFERENCE, "") onBackPressedDispatcher.addCallback(this, onBackPressedCallback) @@ -749,6 +763,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { }, DividerDrawerItem(), primaryDrawerItem { + identifier = DRAWER_ITEM_DRAFTS nameRes = R.string.action_access_drafts iconRes = R.drawable.ic_notebook onClick = { @@ -757,15 +772,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) } }, - primaryDrawerItem { - nameRes = R.string.action_access_scheduled_posts - iconRes = R.drawable.ic_access_time - onClick = { - startActivityWithDefaultTransition( - ScheduledStatusActivityIntent(context, pachliAccountId), - ) - } - }, primaryDrawerItem { identifier = DRAWER_ITEM_ANNOUNCEMENTS nameRes = R.string.title_announcements @@ -840,6 +846,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } + // If the server supports scheduled posts then add a "Scheduled posts" item + // after the "Drafts" item. + if (serverRepository.flow.replayCache.lastOrNull()?.get()?.can(ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED, ">= 1.0.0".toConstraint()) == true) { + binding.mainDrawer.addItemAtPosition( + binding.mainDrawer.getPosition(DRAWER_ITEM_DRAFTS) + 1, + primaryDrawerItem { + nameRes = R.string.action_access_scheduled_posts + iconRes = R.drawable.ic_access_time + onClick = { + startActivityWithDefaultTransition( + ScheduledStatusActivityIntent(binding.mainDrawer.context, pachliAccountId), + ) + } + }, + ) + } + if (BuildConfig.DEBUG) { // Add a "Developer tools" entry. Code that makes it easier to // set the app state at runtime belongs here, it will never @@ -1260,6 +1283,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { /** Drawer identifier for the "Lists" section header. */ private const val DRAWER_ITEM_LISTS: Long = 15 + + /** Drawer identifier for the "Drafts" item. */ + private const val DRAWER_ITEM_DRAFTS = 16L } } diff --git a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt index 5c3ada3ae..244ac70a7 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt @@ -505,6 +505,15 @@ class ComposeActivity : } } + // Hide the "Schedule" button if the server can't schedule. Simply + // disabling it could be confusing to users wondering why they can't + // use it. + lifecycleScope.launch { + viewModel.serverCanSchedule.collect { + binding.composeScheduleButton.visible(it) + } + } + lifecycleScope.launch { viewModel.media.combine(viewModel.poll) { media, poll -> val active = poll == null && diff --git a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt index 475299c79..95f8eb8cc 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt @@ -35,6 +35,8 @@ import app.pachli.core.common.string.mastodonLength import app.pachli.core.common.string.randomAlphanumericString import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.InstanceInfoRepository +import app.pachli.core.data.repository.ServerRepository +import app.pachli.core.model.ServerOperation import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions.ComposeKind import app.pachli.core.network.model.Attachment @@ -49,17 +51,22 @@ import at.connyduck.calladapter.networkresult.fold import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result +import com.github.michaelbull.result.get import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.mapBoth import com.github.michaelbull.result.mapError import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -73,6 +80,7 @@ class ComposeViewModel @Inject constructor( private val serviceClient: ServiceClient, private val draftHelper: DraftHelper, instanceInfoRepo: InstanceInfoRepository, + private val serverRepository: ServerRepository, ) : ViewModel() { /** The current content */ @@ -140,6 +148,11 @@ class ComposeViewModel @Inject constructor( private val _statusLength = MutableStateFlow(0) val statusLength = _statusLength.asStateFlow() + /** Flow of whether or not the server can schedule posts. */ + val serverCanSchedule = serverRepository.flow.map { + it.get()?.can(ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED, ">= 1.0.0".toConstraint()) == true + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + private lateinit var composeKind: ComposeKind // Used in ComposeActivity to pass state to result function when cropImage contract inflight diff --git a/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt b/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt index 16dfbf779..aa14def7c 100644 --- a/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt +++ b/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt @@ -77,4 +77,13 @@ enum class ServerOperation(id: String, versions: List) { ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY("org.joinmastodon.search.query:in:library", listOf(Version(major = 1))), ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC("org.joinmastodon.search.query:in:public", listOf(Version(major = 1))), ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE("org.joinmastodon.search.query:in:public", listOf(Version(major = 1))), + + /** Post a status with a `scheduled_at` property, and edit scheduled statuses. */ + ORG_JOINMASTODON_STATUSES_SCHEDULED( + "org.joinmastodon.statuses.scheduled", + listOf( + // Initial introduction in Mastodon 2.7.0. + Version(major = 1), + ), + ), } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt index 19820326d..755623c75 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt @@ -52,6 +52,7 @@ import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_PU import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE +import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE import app.pachli.core.network.Server.Error.UnparseableVersion import app.pachli.core.network.model.InstanceV1 @@ -241,6 +242,11 @@ data class Server( when (kind) { // Glitch has the same version number as upstream Mastodon GLITCH, MASTODON -> { + // Scheduled statuses + when { + v >= "2.7.0".toVersion() -> c[ORG_JOINMASTODON_STATUSES_SCHEDULED] = "1.0.0".toVersion() + } + // Client filtering when { v >= "3.1.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_CLIENT] = "1.1.0".toVersion() @@ -281,6 +287,8 @@ data class Server( } GOTOSOCIAL -> { + // Can't do scheduled posts, https://github.com/superseriousbusiness/gotosocial/issues/1006 + // Filters when { // Implemented in https://github.com/superseriousbusiness/gotosocial/pull/2936 @@ -305,12 +313,18 @@ data class Server( FIREFISH -> { } // Sharkey can't filter, https://activitypub.software/TransFem-org/Sharkey/-/issues/492 - SHARKEY -> { } + SHARKEY -> { + // Assume scheduled support (may be wrong). + c[ORG_JOINMASTODON_STATUSES_SCHEDULED] = "1.0.0".toVersion() + } FRIENDICA -> { - // Assume filter support (may be wrong) + // Assume filter support (may be wrong). c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion() + // Assume scheduled support (may be wrong). + c[ORG_JOINMASTODON_STATUSES_SCHEDULED] = "1.0.0".toVersion() + // Search when { // Friendica has a number of search operators that are not in Mastodon. @@ -323,10 +337,16 @@ data class Server( } } - // Everything else. Assume server side filtering and no translation. This may be an - // incorrect assumption. + // Everything else. Assume: + // + // - server side filtering + // - scheduled status support + // - no translation + // + // This may be an incorrect assumption. AKKOMA, FEDIBIRD, HOMETOWN, ICESHRIMP, PIXELFED, PLEROMA, UNKNOWN -> { c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion() + c[ORG_JOINMASTODON_STATUSES_SCHEDULED] = "1.0.0".toVersion() } } return c diff --git a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt index d6718800a..04f7154cc 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt +++ b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt @@ -41,6 +41,7 @@ import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_LI import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE +import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE import app.pachli.core.network.model.Account import app.pachli.core.network.model.Configuration @@ -148,6 +149,7 @@ class ServerTest( ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(), ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_SCHEDULED to "1.0.0".toVersion(), ), ), ), @@ -170,6 +172,7 @@ class ServerTest( ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(), ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_SCHEDULED to "1.0.0".toVersion(), ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.0.0".toVersion(), ), ), @@ -205,6 +208,7 @@ class ServerTest( ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE to "1.0.0".toVersion(), ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY to "1.0.0".toVersion(), ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_SCHEDULED to "1.0.0".toVersion(), ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.1.0".toVersion(), ), ), @@ -212,7 +216,7 @@ class ServerTest( ), arrayOf( Triple( - "GoToSocial has no translation or filtering", + "GoToSocial has no translation, filtering, or scheduling", NodeInfo.Software("gotosocial", "0.13.1 git-ccecf5a"), defaultInstance, ), @@ -260,7 +264,7 @@ class ServerTest( ), arrayOf( Triple( - "Pleroma can filter", + "Pleroma can filter, schedule", NodeInfo.Software("pleroma", "2.6.50-875-g2eb5c453.service-origin+soapbox"), defaultInstance, ), @@ -270,13 +274,14 @@ class ServerTest( version = "2.6.50-875-g2eb5c453.service-origin+soapbox".toVersion(), capabilities = mapOf( ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_SCHEDULED to "1.0.0".toVersion(), ), ), ), ), arrayOf( Triple( - "Akkoma can filter", + "Akkoma can filter, schedule", NodeInfo.Software("akkoma", "3.9.3-0-gd83f5f66f-blob"), defaultInstance, ), @@ -286,6 +291,7 @@ class ServerTest( version = "3.9.3-0-gd83f5f66f-blob".toVersion(), capabilities = mapOf( ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_SCHEDULED to "1.0.0".toVersion(), ), ), ), @@ -306,7 +312,7 @@ class ServerTest( ), arrayOf( Triple( - "Friendica can filter", + "Friendica can filter, schedule", NodeInfo.Software("friendica", "2023.05-1542"), defaultInstance, ), @@ -316,6 +322,7 @@ class ServerTest( version = "2023.5.0".toVersion(), capabilities = mapOf( ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_SCHEDULED to "1.0.0".toVersion(), ), ), ),