diff --git a/.github/workflows/apk-on-comment.yml b/.github/workflows/apk-on-comment.yml index 7c44e4866..27ed1fbf8 100644 --- a/.github/workflows/apk-on-comment.yml +++ b/.github/workflows/apk-on-comment.yml @@ -25,7 +25,7 @@ jobs: status: pending - name: Checkout PR branch ${{ steps.comment-branch.outputs.head_ref }} / ${{ steps.comment-branch.outputs.head_sha }} - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 with: ref: ${{ steps.comment-branch.outputs.head_sha }} @@ -53,7 +53,7 @@ jobs: - name: Upload APK Release Asset id: upload-release-asset-apk - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4 with: name: app-release.apk path: ${{steps.sign_app_apk.outputs.signedReleaseFile}} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdd9c0da..aabd5b552 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - uses: ./.github/actions/setup-build-env with: @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - uses: ./.github/actions/setup-build-env with: @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - uses: ./.github/actions/setup-build-env with: @@ -75,7 +75,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - uses: ./.github/actions/setup-build-env with: @@ -96,7 +96,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - uses: ./.github/actions/setup-build-env with: @@ -117,7 +117,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - uses: ./.github/actions/setup-build-env with: diff --git a/.github/workflows/pr-conventional-commit.yml b/.github/workflows/pr-conventional-commit.yml index 8710b7ddc..d26ec7c91 100644 --- a/.github/workflows/pr-conventional-commit.yml +++ b/.github/workflows/pr-conventional-commit.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: PR Conventional Commit Validation - uses: ytanikin/PRConventionalCommits@8d258b54939f6769fcd935a52b96d6b0383a00c5 # 1.2.0 + uses: ytanikin/PRConventionalCommits@b628c5a234cc32513014b7bfdd1e47b532124d98 # 1.3.0 with: task_types: '["build","change","chore","ci","deprecate","docs","feat","fix","perf","refactor","remove","revert","security","style","test"]' add_label: 'false' diff --git a/.github/workflows/upload-blue-release-google-play.yml b/.github/workflows/upload-blue-release-google-play.yml index 4f1dc0ac5..f1d40d808 100644 --- a/.github/workflows/upload-blue-release-google-play.yml +++ b/.github/workflows/upload-blue-release-google-play.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 - uses: ./.github/actions/setup-build-env with: @@ -47,7 +47,7 @@ jobs: - name: Upload APK Release Asset id: upload-release-asset-apk - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4 with: name: app-release.apk path: ${{steps.sign_app_apk.outputs.signedReleaseFile}} diff --git a/.github/workflows/upload-orange-release-google-play.yml b/.github/workflows/upload-orange-release-google-play.yml index 1bba78082..3d09e6222 100644 --- a/.github/workflows/upload-orange-release-google-play.yml +++ b/.github/workflows/upload-orange-release-google-play.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 with: fetch-depth: 0 @@ -52,7 +52,7 @@ jobs: - name: Upload APK Release Asset id: upload-release-asset-apk - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4 with: name: app-release.apk path: ${{steps.sign_app_apk.outputs.signedReleaseFile}} diff --git a/app/src/main/java/app/pachli/MainActivity.kt b/app/src/main/java/app/pachli/MainActivity.kt index 021d04ccb..59d1cd3cc 100644 --- a/app/src/main/java/app/pachli/MainActivity.kt +++ b/app/src/main/java/app/pachli/MainActivity.kt @@ -159,6 +159,7 @@ 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 @@ -368,6 +369,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { viewModel.uiState.collect { uiState -> bindMainDrawerSearch(this@MainActivity, initialAccount.id, uiState.hideTopToolbar) bindMainDrawerProfileHeader(uiState) + bindMainDrawerScheduledPosts(this@MainActivity, initialAccount.id, uiState.canSchedulePost) updateShortCuts(uiState.accounts) } } @@ -762,9 +764,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } // Add a "Search" menu item. - binding.mainDrawer.addItemsAtPosition( + binding.mainDrawer.addItemAtPosition( 4, primaryDrawerItem { + identifier = DRAWER_ITEM_SEARCH nameRes = R.string.action_search iconicsIcon = GoogleMaterial.Icon.gmd_search onClick = { @@ -779,6 +782,45 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) } + /** + * Binds the "Scheduled posts" menu item in the main drawer. + * + * @param context + * @param pachliAccountId + * @param showSchedulePosts True if a "Scheduled posts" menu item should be added + * to the list, false if any existing item should be removed. + */ + private fun bindMainDrawerScheduledPosts(context: Context, pachliAccountId: Long, showSchedulePosts: Boolean) { + val existingPosition = binding.mainDrawer.getPosition(DRAWER_ITEM_SCHEDULED_POSTS) + val showing = existingPosition != -1 + + if (showing == showSchedulePosts) return + + if (!showSchedulePosts) { + binding.mainDrawer.removeItemByPosition(existingPosition) + return + } + + // Add the "Scheduled posts" item immediately after "Drafts" + binding.mainDrawer.addItemAtPosition( + binding.mainDrawer.getPosition(DRAWER_ITEM_DRAFTS) + 1, + primaryDrawerItem { + identifier = DRAWER_ITEM_SCHEDULED_POSTS + nameRes = R.string.action_access_scheduled_posts + iconRes = R.drawable.ic_access_time + onClick = { + startActivityWithDefaultTransition( + ScheduledStatusActivityIntent(context, pachliAccountId), + ) + } + }, + ) + + updateMainDrawerTypeface( + EmbeddedFontFamily.from(sharedPreferencesRepository.getString(FONT_FAMILY, "default")), + ) + } + /** Binds [lists] to the "Lists" section in the main drawer. */ private fun bindMainDrawerLists(pachliAccountId: Long, lists: List) { binding.mainDrawer.removeItems(*listDrawerItems.toTypedArray()) @@ -925,6 +967,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { }, DividerDrawerItem(), primaryDrawerItem { + identifier = DRAWER_ITEM_DRAFTS nameRes = R.string.action_access_drafts iconRes = R.drawable.ic_notebook onClick = { @@ -933,15 +976,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 @@ -1375,14 +1409,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } companion object { - private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 - private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 + private const val DRAWER_ITEM_ADD_ACCOUNT = -13L + private const val DRAWER_ITEM_ANNOUNCEMENTS = 14L /** Drawer identifier for the "Lists" section header. */ - private const val DRAWER_ITEM_LISTS: Long = 15 + private const val DRAWER_ITEM_LISTS = 15L + + /** Drawer identifier for the "Drafts" item. */ + private const val DRAWER_ITEM_DRAFTS = 16L /** Drawer identifier for the "Search" item. */ - private const val DRAWER_ITEM_SEARCH: Long = 16 + private const val DRAWER_ITEM_SEARCH = 17L + + /** Drawer identifier for the "Scheduled posts" item. */ + private const val DRAWER_ITEM_SCHEDULED_POSTS = 18L } } diff --git a/app/src/main/java/app/pachli/MainViewModel.kt b/app/src/main/java/app/pachli/MainViewModel.kt index 4f317d0b9..7859a7eaa 100644 --- a/app/src/main/java/app/pachli/MainViewModel.kt +++ b/app/src/main/java/app/pachli/MainViewModel.kt @@ -21,10 +21,12 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.pachli.core.common.PachliError +import app.pachli.core.data.model.Server import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.RefreshAccountError import app.pachli.core.data.repository.SetActiveAccountError import app.pachli.core.database.model.AccountEntity +import app.pachli.core.model.ServerOperation import app.pachli.core.model.Timeline import app.pachli.core.preferences.MainNavigationPosition import app.pachli.core.preferences.PrefKeys @@ -34,6 +36,7 @@ import com.github.michaelbull.result.Result import com.github.michaelbull.result.mapEither import com.github.michaelbull.result.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow @@ -102,6 +105,7 @@ internal sealed class UiError( * @param mainNavigationPosition See [SharedPreferencesRepository.mainNavigationPosition]. * @param displaySelfUsername See [ShowSelfUsername]. * @param accounts Unordered list of available accounts. + * @param canSchedulePost True if the account can schedule posts */ data class UiState( val animateAvatars: Boolean, @@ -111,9 +115,10 @@ data class UiState( val mainNavigationPosition: MainNavigationPosition, val displaySelfUsername: Boolean, val accounts: List, + val canSchedulePost: Boolean, ) { companion object { - fun make(prefs: SharedPreferencesRepository, accounts: List) = UiState( + fun make(prefs: SharedPreferencesRepository, accounts: List, server: Server?) = UiState( animateAvatars = prefs.animateAvatars, animateEmojis = prefs.animateEmojis, enableTabSwipe = prefs.enableTabSwipe, @@ -125,6 +130,7 @@ data class UiState( ShowSelfUsername.NEVER -> false }, accounts = accounts, + canSchedulePost = server?.can(ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED, ">= 1.0.0".toConstraint()) == true, ) } } @@ -162,15 +168,23 @@ internal class MainViewModel @Inject constructor( PrefKeys.SHOW_SELF_USERNAME, ) - val uiState = sharedPreferencesRepository.changes.filter { watchedPrefs.contains(it) }.onStart { emit(null) } - .combine(accountManager.accountsFlow) { _, accounts -> - UiState.make(sharedPreferencesRepository, accounts) + val uiState = + combine( + sharedPreferencesRepository.changes.filter { watchedPrefs.contains(it) }.onStart { emit(null) }, + accountManager.accountsFlow, + pachliAccountFlow, + ) { _, accounts, pachliAccount -> + UiState.make( + sharedPreferencesRepository, + accounts, + pachliAccount.server, + ) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = UiState.make(sharedPreferencesRepository, accountManager.accounts), - ) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.make(sharedPreferencesRepository, accountManager.accounts, null), + ) init { viewModelScope.launch { uiAction.collect { launch { onUiAction(it) } } } diff --git a/app/src/main/java/app/pachli/PachliApplication.kt b/app/src/main/java/app/pachli/PachliApplication.kt index 0e89a9fda..4a816f15b 100644 --- a/app/src/main/java/app/pachli/PachliApplication.kt +++ b/app/src/main/java/app/pachli/PachliApplication.kt @@ -150,9 +150,13 @@ class PachliApplication : Application() { // General usage is: // // if (oldVersion < ...) { - // ... use editor modify the preferences ... + // ... use `editor` to modify the preferences ... // } + if (oldVersion < 2024101701) { + editor.remove(PrefKeys.Deprecated.WELLBEING_LIMITED_NOTIFICATIONS) + } + editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() } 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 bbec21a21..d8caed062 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 2f7c93fb4..3d87312c2 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 @@ -51,17 +53,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 @@ -75,6 +82,7 @@ class ComposeViewModel @Inject constructor( private val serviceClient: ServiceClient, private val draftHelper: DraftHelper, instanceInfoRepo: InstanceInfoRepository, + private val serverRepository: ServerRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, ) : ViewModel() { @@ -143,6 +151,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/app/src/main/java/app/pachli/components/compose/view/ComposeScheduleView.kt b/app/src/main/java/app/pachli/components/compose/view/ComposeScheduleView.kt index 188fdb049..76a094a00 100644 --- a/app/src/main/java/app/pachli/components/compose/view/ComposeScheduleView.kt +++ b/app/src/main/java/app/pachli/components/compose/view/ComposeScheduleView.kt @@ -19,11 +19,15 @@ package app.pachli.components.compose.view import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater +import android.widget.Button import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import app.pachli.R import app.pachli.databinding.ViewComposeScheduleBinding +import com.google.android.material.R as MaterialR import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointForward import com.google.android.material.datepicker.MaterialDatePicker @@ -158,6 +162,30 @@ class ComposeScheduleView pickerBuilder.setTimeFormat(getTimeFormat(context)) val picker = pickerBuilder.build() + + // Work around https://github.com/material-components/material-components-android/issues/3584 + // where the buttons get cut off because of incorrect constraints. Force the + // constraints when the dialog resumes. + picker.lifecycle.addObserver( + object : DefaultLifecycleObserver { + fun Button.constrainToBottomOfParent() { + (layoutParams as? LayoutParams)?.let { lp -> + lp.bottomToBottom = LayoutParams.PARENT_ID + layoutParams = lp + } + } + + override fun onResume(owner: LifecycleOwner) { + picker.dialog + ?.findViewById