Merge branch 'develop' into refactor_instancemute

This commit is contained in:
Tak! 2023-09-06 08:55:34 +02:00
commit a96460cb16
252 changed files with 5810 additions and 2827 deletions

24
.github/ci-gradle.properties vendored Normal file
View File

@ -0,0 +1,24 @@
#
# Copyright 2023 Tusky Contributors
#
# This file is a part of Tusky.
#
# 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.
#
# Tusky 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 Tusky; if not,
# see <http://www.gnu.org/licenses>.
#
# CI build workers are ephemeral, so don't benefit from the Gradle daemon
org.gradle.daemon=false
org.gradle.parallel=true
org.gradle.workers.max=2
kotlin.incremental=false
kotlin.compiler.execution.strategy=in-process

44
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: CI
on:
push:
tags:
- '*'
pull_request:
workflow_dispatch:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Gradle Build Action
uses: gradle/gradle-build-action@v2
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
- name: ktlint
run: ./gradlew clean ktlintCheck
- name: Regular lint
run: ./gradlew app:lintGreenDebug
- name: Test
run: ./gradlew app:testGreenDebugUnitTest
- name: Build
run: ./gradlew app:buildGreenDebug

35
.github/workflows/ktlint.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: reviewdog-suggester
on: pull_request
jobs:
ktlint:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
cache: 'gradle'
- run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- uses: gradle/wrapper-validation-action@v1
- uses: gradle/gradle-build-action@v2
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
- run: chmod +x ./gradlew
- run: ./gradlew ktlintFormat
- uses: reviewdog/action-suggester@v1
with:
tool_name: ktlintFormat
permissions:
contents: read
issues: write
pull-requests: write

View File

@ -0,0 +1,34 @@
# Build the app on each push to `develop`, populating the build cache to speed
# up CI on PRs.
name: Populate build cache
on:
push:
branches:
- develop
jobs:
build:
name: app:buildGreenDebug
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- uses: gradle/gradle-build-action@v2
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
- name: Run app:buildGreenDebug
run: ./gradlew app:buildGreenDebug

View File

@ -6,6 +6,34 @@
### Significant bug fixes
## v23.0
### New features and other improvements
- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton)
### Significant bug fixes
- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck)
- If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account.
- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton)
- Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below
- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton)
- Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes.
- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak)
- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck)
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
## v23.0 beta 2
### Significant bug fixes
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
## v23.0 beta 1
### New features and other improvements

View File

@ -32,4 +32,4 @@ If you have any bug reports, feature requests or questions please open an issue
We always welcome new contributors! Please read our [contribution guide](https://github.com/tuskyapp/Tusky/blob/develop/CONTRIBUTING.md) to get started.
### Development chatroom
https://riot.im/app/#/room/#Tusky:matrix.org
https://matrix.to/#/#Tusky:matrix.org

View File

@ -29,8 +29,8 @@ android {
namespace "com.keylesspalace.tusky"
minSdk 23
targetSdk 33
versionCode 111
versionName "23.0 beta 1"
versionCode 113
versionName "23.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -60,8 +60,7 @@ android {
lint {
lintConfig file("lint.xml")
// Regenerate by deleting app/lint-baseline.xml, then run:
// ./gradlew lintBlueDebug
// Regenerate by running `./gradlew app:newLintBaseline`
baseline = file("lint-baseline.xml")
}
@ -103,8 +102,8 @@ android {
// Can remove this once https://issuetracker.google.com/issues/260059413 is fixed.
// https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
applicationVariants.configureEach { variant ->
variant.outputs.configureEach {
@ -147,7 +146,7 @@ dependencies {
implementation libs.conscrypt.android
implementation libs.bundles.glide
kapt libs.glide.compiler
ksp libs.glide.compiler
implementation libs.bundles.rxjava3
@ -158,7 +157,7 @@ dependencies {
implementation libs.sparkbutton
implementation libs.photoview
implementation libs.touchimageview
implementation libs.bundles.material.drawer
implementation libs.material.typeface
@ -186,3 +185,35 @@ dependencies {
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.androidx.test.junit
}
// Work around warnings of:
// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context()
// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred
tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask) {
kaptProcessJvmArgs.addAll([
"--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
"--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"])
}
tasks.register("newLintBaseline") {
description 'Deletes and then recreates the lint baseline'
// This task should always run, irrespective of caching
notCompatibleWithConfigurationCache("Is always out of date")
outputs.upToDateWhen { false }
doLast {
delete android.lint.baseline.path
}
// Regenerate the lint baseline
it.finalizedBy tasks.named("lintBlueDebug")
}

File diff suppressed because one or more lines are too long

View File

@ -33,9 +33,22 @@
<!-- Logs are stripped in release builds. -->
<issue id="LogConditional" severity="ignore" />
<!-- Newer dependencies are handled by Renovate, and don't need a warning -->
<issue id="GradleDependency" severity="ignore" />
<!-- Typographical quotes are not something we care about at the moment -->
<issue id="TypographyQuotes" severity="ignore" />
<!-- Ensure we are warned about errors in the baseline -->
<issue id="LintBaseline" severity="warning" />
<!-- Warn about typos. The typo database in lint is not exhaustive, and it's unclear
how to add to it when it's wrong. -->
<issue id="Typos" severity="warning" />
<!-- Set OldTargetApi back to warning -->
<issue id="OldTargetApi" severity="warning" />
<!-- Mark all other lint issues as errors -->
<issue id="all" severity="error" />
</lint>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,10 @@
package com.keylesspalace.tusky
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.SpannableString
import android.text.SpannableStringBuilder
@ -8,13 +12,21 @@ import android.text.method.LinkMovementMethod
import android.text.style.URLSpan
import android.text.util.Linkify
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityAboutBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.NoUnderlineURLSpan
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import kotlinx.coroutines.launch
import javax.inject.Inject
class AboutActivity : BottomSheetActivity(), Injectable {
@Inject
lateinit var instanceInfoRepository: InstanceInfoRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -32,6 +44,28 @@ class AboutActivity : BottomSheetActivity(), Injectable {
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
binding.deviceInfo.text = getString(
R.string.about_device_info,
Build.MANUFACTURER,
Build.MODEL,
Build.VERSION.RELEASE,
Build.VERSION.SDK_INT
)
lifecycleScope.launch {
accountManager.activeAccount?.let { account ->
val instanceInfo = instanceInfoRepository.getInstanceInfo()
binding.accountInfo.text = getString(
R.string.about_account_info,
account.username,
account.domain,
instanceInfo.version
)
binding.accountInfoTitle.show()
binding.accountInfo.show()
}
}
if (BuildConfig.CUSTOM_INSTANCE.isBlank()) {
binding.aboutPoweredByTusky.hide()
}
@ -47,6 +81,16 @@ class AboutActivity : BottomSheetActivity(), Injectable {
binding.aboutLicensesButton.setOnClickListener {
startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java))
}
binding.copyDeviceInfo.setOnClickListener {
val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}"
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Tusky version information", text)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show()
}
}
}
}

View File

@ -56,6 +56,8 @@ import java.util.List;
import javax.inject.Inject;
import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME;
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
private static final String TAG = "BaseActivity";
@ -74,7 +76,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
/* There isn't presently a way to globally change the theme of a whole application at
* runtime, just individual activities. So, each activity has to set its theme before any
* views are created. */
String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT);
String theme = preferences.getString(APP_THEME, ThemeUtils.APP_THEME_DEFAULT);
Log.d("activeTheme", theme);
if (theme.equals("black")) {
setTheme(R.style.TuskyBlackTheme);
@ -256,9 +258,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account.getId());
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.REDIRECT_URL, url);
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
startActivity(intent);
finishWithoutSlideOutAnimation();
}

View File

@ -87,7 +87,7 @@ abstract class BottomSheetActivity : BaseActivity() {
viewThread(statuses[0].id, statuses[0].url)
return@subscribe
}
accounts.firstOrNull { it.url == url }?.let { account ->
accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account ->
// Some servers return (unrelated) accounts for url searches (#2804)
// Verify that the account's url matches the query
viewAccount(account.id)

View File

@ -25,7 +25,9 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
@ -46,9 +48,11 @@ import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.await
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.keylesspalace.tusky.viewmodel.ProfileDataInUi
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -96,6 +100,14 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
private val currentProfileData
get() = ProfileDataInUi(
displayName = binding.displayNameEditText.text.toString(),
note = binding.noteEditText.text.toString(),
locked = binding.lockedCheckBox.isChecked,
fields = accountFieldEditAdapter.getFieldData()
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -200,17 +212,26 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
}
val onBackCallback = object : OnBackPressedCallback(enabled = true) {
override fun handleOnBackPressed() = checkForUnsavedChanges()
}
onBackPressedDispatcher.addCallback(this, onBackCallback)
}
fun checkForUnsavedChanges() {
if (viewModel.hasUnsavedChanges(currentProfileData)) {
showUnsavedChangesDialog()
} else {
finish()
}
}
override fun onStop() {
super.onStop()
if (!isFinishing) {
viewModel.updateProfile(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
viewModel.updateProfile(currentProfileData)
}
}
@ -287,14 +308,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
return super.onOptionsItemSelected(item)
}
private fun save() {
viewModel.save(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
}
private fun save() = viewModel.save(currentProfileData)
private fun onSaveFailure(msg: String?) {
val errorMsg = msg ?: getString(R.string.error_media_upload_sending)
@ -306,4 +320,16 @@ class EditProfileActivity : BaseActivity(), Injectable {
Log.w("EditProfileActivity", "failed to pick media", throwable)
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
}
private fun showUnsavedChangesDialog() = lifecycleScope.launch {
when (launchSaveDialog()) {
AlertDialog.BUTTON_POSITIVE -> save()
else -> finish()
}
}
private suspend fun launchSaveDialog() = AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_save_profile_changes_message))
.create()
.await(R.string.action_save, R.string.action_discard)
}

View File

@ -23,8 +23,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
@ -38,10 +36,10 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.databinding.DialogListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
@ -118,7 +116,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
viewModel.events.collect { event ->
when (event) {
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list)
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
}
}
@ -126,16 +124,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
private fun showlistNameDialog(list: MastoList?) {
val layout = FrameLayout(this)
val editText = EditText(this)
editText.setHint(R.string.hint_list_name)
layout.addView(editText)
val margin = Utils.dpToPx(this, 8)
(editText.layoutParams as ViewGroup.MarginLayoutParams)
.setMargins(margin, margin, margin, 0)
val binding = DialogListBinding.inflate(layoutInflater)
val dialog = AlertDialog.Builder(this)
.setView(layout)
.setView(binding.root)
.setPositiveButton(
if (list == null) {
R.string.action_create_list
@ -143,17 +134,26 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
R.string.action_rename_list
}
) { _, _ ->
onPickedDialogName(editText.text, list?.id)
onPickedDialogName(binding.nameText.text.toString(), list?.id, binding.exclusiveCheckbox.isChecked)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
editText.doOnTextChanged { s, _, _, _ ->
positiveButton.isEnabled = s?.isNotBlank() == true
binding.nameText.let { editText ->
editText.doOnTextChanged { s, _, _, _ ->
dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true
}
editText.setText(list?.title)
editText.text?.let { editText.setSelection(it.length) }
}
list?.let {
if (it.exclusive == null) {
binding.exclusiveCheckbox.visible(false)
} else {
binding.exclusiveCheckbox.isChecked = it.exclusive
}
}
editText.setText(list?.title)
editText.text?.let { editText.setSelection(it.length) }
}
private fun showListDeleteDialog(list: MastoList) {
@ -174,13 +174,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
INITIAL, LOADING -> binding.messageView.hide()
ERROR_NETWORK -> {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
viewModel.retryLoading()
}
}
ERROR_OTHER -> {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
viewModel.retryLoading()
}
}
@ -192,6 +192,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
R.string.message_empty,
null
)
binding.messageView.showHelp(R.string.help_empty_lists)
} else {
binding.messageView.hide()
}
@ -226,7 +227,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.list_edit -> openListSettings(list)
R.id.list_rename -> renameListDialog(list)
R.id.list_update -> renameListDialog(list)
R.id.list_delete -> showListDeleteDialog(list)
else -> return@setOnMenuItemClickListener false
}
@ -287,11 +288,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
}
private fun onPickedDialogName(name: CharSequence, listId: String?) {
private fun onPickedDialogName(name: String, listId: String?, exclusive: Boolean) {
if (listId == null) {
viewModel.createNewList(name.toString())
viewModel.createNewList(name, exclusive)
} else {
viewModel.renameList(listId, name.toString())
viewModel.updateList(listId, name, exclusive)
}
}

View File

@ -16,6 +16,8 @@
package com.keylesspalace.tusky
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
@ -33,6 +35,7 @@ import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
import android.view.View
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
@ -41,9 +44,12 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.GravityCompat
import androidx.core.view.MenuProvider
import androidx.core.view.forEach
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
@ -100,7 +106,6 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -158,7 +163,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private lateinit var header: AccountHeaderView
private var notificationTabPosition = 0
private var onTabSelectedListener: OnTabSelectedListener? = null
private var unreadAnnouncementsCount = 0
@ -167,8 +171,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private lateinit var glide: RequestManager
private var accountLocked: Boolean = false
// We need to know if the emoji pack has been changed
private var selectedEmojiPack: String? = null
@ -178,6 +180,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
/** Adapter for the different timeline tabs */
private lateinit var tabAdapter: MainPagerAdapter
@SuppressLint("RestrictedApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -185,30 +188,39 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
?: return // will be redirected to LoginActivity by BaseActivity
var showNotificationTab = false
if (intent != null) {
// check for savedInstanceState in order to not handle intent events more than once
if (intent != null && savedInstanceState == null) {
val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1)
if (notificationId != -1) {
// opened from a notification action, cancel the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId)
}
/** there are two possibilities the accountId can be passed to MainActivity:
* - from our code as long 'account_id'
* - from our code as Long Intent Extra TUSKY_ACCOUNT_ID
* - from share shortcuts as String 'android.intent.extra.shortcut.ID'
*/
var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1)
if (accountId == -1L) {
var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1)
if (tuskyAccountId == -1L) {
val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
if (accountIdString != null) {
accountId = accountIdString.toLong()
tuskyAccountId = accountIdString.toLong()
}
}
val accountRequested = accountId != -1L
if (accountRequested && accountId != activeAccount.id) {
accountManager.setActiveAccount(accountId)
val accountRequested = tuskyAccountId != -1L
if (accountRequested && tuskyAccountId != activeAccount.id) {
accountManager.setActiveAccount(tuskyAccountId)
}
val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
if (canHandleMimeType(intent.type)) {
if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) {
// Sharing to Tusky from an external app
if (accountRequested) {
// The correct account is already active
forwardShare(intent)
forwardToComposeActivity(intent)
} else {
// No account was provided, show the chooser
showAccountChooserDialog(
@ -219,10 +231,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val requestedId = account.id
if (requestedId == activeAccount.id) {
// The correct account is already active
forwardShare(intent)
forwardToComposeActivity(intent)
} else {
// A different account was requested, restart the activity
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
intent.putExtra(TUSKY_ACCOUNT_ID, requestedId)
changeAccount(requestedId, intent)
}
}
@ -232,11 +244,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} else if (openDrafts) {
val intent = DraftsActivity.newIntent(this)
startActivity(intent)
} else if (accountRequested && savedInstanceState == null) {
} else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) {
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
// otherwise show notification tab
if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true)
if (intent.getSerializableExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST) {
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS)
startActivityWithSlideInAnimation(intent)
} else {
showNotificationTab = true
@ -245,7 +257,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
setContentView(binding.root)
setSupportActionBar(binding.mainToolbar)
glide = Glide.with(this)
@ -254,8 +265,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
startActivity(composeIntent)
}
// Determine which of the three toolbars should be the supportActionBar (which hosts
// the options menu).
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
binding.mainToolbar.visible(!hideTopToolbar)
if (hideTopToolbar) {
when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) {
"top" -> setSupportActionBar(binding.topNav)
"bottom" -> setSupportActionBar(binding.bottomNav)
}
binding.mainToolbar.hide()
// There's not enough space in the top/bottom bars to show the title as well.
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
setSupportActionBar(binding.mainToolbar)
binding.mainToolbar.show()
}
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
@ -266,7 +290,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
setupDrawer(
savedInstanceState,
addSearchButton = hideTopToolbar,
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS)
)
/* Fetch user info while we're doing other things. This has to be done after setting up the
@ -291,7 +315,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
is MainTabsChangedEvent -> {
refreshMainDrawerItems(
addSearchButton = hideTopToolbar,
addTrendingButton = !event.newTabs.hasTab(TRENDING)
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS)
)
setupTabs(false)
@ -353,6 +377,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
// If the main toolbar is hidden then there's no space in the top/bottomNav to show
// the menu items as icons, so forceably disable them
if (!binding.mainToolbar.isVisible) menu.forEach { it.setShowAsAction(SHOW_AS_ACTION_NEVER) }
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_search -> {
@ -425,12 +457,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
private fun forwardShare(intent: Intent) {
val composeIntent = Intent(this, ComposeActivity::class.java)
composeIntent.action = intent.action
composeIntent.type = intent.type
composeIntent.putExtras(intent)
composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
private fun forwardToComposeActivity(intent: Intent) {
val composeOptions = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS, ComposeActivity.ComposeOptions::class.java)
val composeIntent = if (composeOptions != null) {
ComposeActivity.startIntent(this, composeOptions)
} else {
Intent(this, ComposeActivity::class.java).apply {
action = intent.action
type = intent.type
putExtras(intent)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
startActivity(composeIntent)
finish()
}
@ -438,13 +477,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun setupDrawer(
savedInstanceState: Bundle?,
addSearchButton: Boolean,
addTrendingButton: Boolean
addTrendingTagsButton: Boolean
) {
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
binding.topNavAvatar.setOnClickListener(drawerOpenClickListener)
binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener)
binding.topNav.setNavigationOnClickListener(drawerOpenClickListener)
binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener)
header = AccountHeaderView(this).apply {
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
@ -499,12 +538,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
})
binding.mainDrawer.apply {
refreshMainDrawerItems(addSearchButton, addTrendingButton)
refreshMainDrawerItems(addSearchButton, addTrendingTagsButton)
setSavedInstance(savedInstanceState)
}
}
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingTagsButton: Boolean) {
binding.mainDrawer.apply {
itemAdapter.clear()
tintStatusBar = true
@ -538,7 +577,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS)
startActivityWithSlideInAnimation(intent)
}
},
@ -621,7 +660,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
)
}
if (addTrendingButton) {
if (addTrendingTagsButton) {
binding.mainDrawer.addItemsAtPosition(
5,
primaryDrawerItem {
@ -756,8 +795,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
activeTabLayout.addOnTabSelectedListener(it)
}
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
supportActionBar?.title = tabs[position].title(this@MainActivity)
binding.mainToolbar.setOnClickListener {
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
@ -872,8 +910,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
disableAllNotifications(this, accountManager)
}
accountLocked = me.locked
updateProfiles()
updateShortcut(this, accountManager.activeAccount!!)
}
@ -882,112 +918,75 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
if (hideTopToolbar) {
val activeToolbar = if (hideTopToolbar) {
val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom"
val avatarView = if (navOnBottom) {
binding.bottomNavAvatar.show()
binding.bottomNavAvatar
if (navOnBottom) {
binding.bottomNav
} else {
binding.topNavAvatar.show()
binding.topNavAvatar
}
if (animateAvatars) {
Glide.with(this)
.load(avatarUrl)
.placeholder(R.drawable.avatar_default)
.into(avatarView)
} else {
Glide.with(this)
.asBitmap()
.load(avatarUrl)
.placeholder(R.drawable.avatar_default)
.into(avatarView)
binding.topNav
}
} else {
binding.bottomNavAvatar.hide()
binding.topNavAvatar.hide()
binding.mainToolbar
}
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
if (animateAvatars) {
glide.asDrawable()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
if (animateAvatars) {
glide.asDrawable().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) resource.start()
activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) {
resource.start()
}
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
} else {
glide.asBitmap()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
override fun onLoadCleared(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
})
} else {
glide.asBitmap().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
.apply {
if (showPlaceholder) placeholder(R.drawable.avatar_default)
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
navIconSize,
navIconSize
)
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
activeToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
navIconSize,
navIconSize
)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
placeholder?.let {
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
}
})
}
}
})
}
}
@ -1049,8 +1048,75 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val REDIRECT_URL = "redirectUrl"
const val OPEN_DRAFTS = "draft"
private const val REDIRECT_URL = "redirectUrl"
private const val OPEN_DRAFTS = "draft"
private const val TUSKY_ACCOUNT_ID = "tuskyAccountId"
private const val COMPOSE_OPTIONS = "composeOptions"
private const val NOTIFICATION_TYPE = "notificationType"
private const val NOTIFICATION_TAG = "notificationTag"
private const val NOTIFICATION_ID = "notificationId"
/**
* Switches the active account to the provided accountId and then stays on MainActivity
*/
@JvmStatic
fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId)
}
}
/**
* Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked
*/
@JvmStatic
fun openNotificationIntent(context: Context, tuskyAccountId: Long, type: Notification.Type): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(NOTIFICATION_TYPE, type)
}
}
/**
* Switches the active account to the accountId and then opens ComposeActivity with the provided options
* @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account.
* @param notificationId optional id of the notification that should be cancelled when this intent is opened
* @param notificationTag optional tag of the notification that should be cancelled when this intent is opened
*/
@JvmStatic
fun composeIntent(
context: Context,
options: ComposeActivity.ComposeOptions,
tuskyAccountId: Long = -1,
notificationTag: String? = null,
notificationId: Int = -1
): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
action = Intent.ACTION_SEND // so it can be opened via shortcuts
putExtra(COMPOSE_OPTIONS, options)
putExtra(NOTIFICATION_TAG, notificationTag)
putExtra(NOTIFICATION_ID, notificationId)
}
}
/**
* switches the active account to the accountId and then tries to resolve and show the provided url
*/
@JvmStatic
fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(REDIRECT_URL, url)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
/**
* switches the active account to the provided accountId and then opens drafts
*/
fun draftIntent(context: Context, tuskyAccountId: Long): Intent {
return accountSwitchIntent(context, tuskyAccountId).apply {
putExtra(OPEN_DRAFTS, true)
}
}
}
}

View File

@ -27,6 +27,8 @@ import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.filters.EditFilterActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
@ -132,6 +134,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
{
followTagItem?.isVisible = false
unfollowTagItem?.isVisible = true
Snackbar.make(binding.root, getString(R.string.following_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
},
{
Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
@ -152,6 +156,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
{
followTagItem?.isVisible = true
unfollowTagItem?.isVisible = false
Snackbar.make(binding.root, getString(R.string.unfollowing_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
},
{
Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
@ -169,6 +175,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
*/
private fun updateMuteTagMenuItems() {
val tag = hashtag ?: return
val hashedTag = "#$tag"
muteTagItem?.isVisible = true
muteTagItem?.isEnabled = false
@ -178,9 +185,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.getFilters().fold(
{ filters ->
mutedFilter = filters.firstOrNull { filter ->
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
it.keyword == tag
}
// TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)?
filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag
}
updateTagMuteState(mutedFilter != null)
},
@ -189,7 +195,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
}
updateTagMuteState(mutedFilterV1 != null)
},
@ -221,6 +227,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
val tag = hashtag ?: return true
lifecycleScope.launch {
var filterCreateSuccess = false
val hashedTag = "#$tag"
mastodonApi.createFilter(
title = "#$tag",
context = listOf(FilterV1.HOME),
@ -228,10 +237,13 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
expiresInSeconds = null
).fold(
{ filter ->
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
mutedFilter = filter
updateTagMuteState(true)
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = hashedTag, wholeWord = true).isSuccess) {
// must be requested again; otherwise does not contain the keyword (but server does)
mutedFilter = mastodonApi.getFilter(filter.id).getOrNull()
// TODO the preference key here ("home") is not meaningful; should probably be another event if any
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
filterCreateSuccess = true
} else {
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to mute #$tag")
@ -240,7 +252,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
mastodonApi.createFilterV1(
tag,
hashedTag,
listOf(FilterV1.HOME),
irreversible = false,
wholeWord = true,
@ -248,8 +260,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
).fold(
{ filter ->
mutedFilterV1 = filter
updateTagMuteState(true)
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
filterCreateSuccess = true
},
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
@ -262,6 +274,24 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
}
}
)
if (filterCreateSuccess) {
updateTagMuteState(true)
Snackbar.make(binding.root, getString(R.string.muting_hashtag_success_format, tag), Snackbar.LENGTH_LONG).apply {
setAction(R.string.action_view_filter) {
val intent = if (mutedFilter != null) {
Intent(this@StatusListActivity, EditFilterActivity::class.java).apply {
putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter)
}
} else {
Intent(this@StatusListActivity, FiltersActivity::class.java)
}
startActivityWithSlideInAnimation(intent)
}
show()
}
}
}
return true
@ -307,6 +337,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
mutedFilterV1 = null
mutedFilter = null
Snackbar.make(binding.root, getString(R.string.unmuting_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
},
{ throwable ->
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()

View File

@ -23,7 +23,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.components.trending.TrendingFragment
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
import java.util.Objects
/** this would be a good case for a sealed class, but that does not work nice with Room */
@ -33,9 +33,10 @@ const val NOTIFICATIONS = "Notifications"
const val LOCAL = "Local"
const val FEDERATED = "Federated"
const val DIRECT = "Direct"
const val TRENDING = "Trending"
const val TRENDING_TAGS = "TrendingTags"
const val HASHTAG = "Hashtag"
const val LIST = "List"
const val BOOKMARKS = "Bookmarks"
data class TabData(
val id: String,
@ -52,9 +53,7 @@ data class TabData(
other as TabData
if (id != other.id) return false
if (arguments != other.arguments) return false
return true
return arguments == other.arguments
}
override fun hashCode() = Objects.hash(id, arguments)
@ -94,11 +93,11 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
icon = R.drawable.ic_reblog_direct_24dp,
fragment = { ConversationsFragment.newInstance() }
)
TRENDING -> TabData(
id = TRENDING,
TRENDING_TAGS -> TabData(
id = TRENDING_TAGS,
text = R.string.title_public_trending_hashtags,
icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingFragment.newInstance() }
fragment = { TrendingTagsFragment.newInstance() }
)
HASHTAG -> TabData(
id = HASHTAG,
@ -116,6 +115,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
arguments = arguments,
title = { arguments.getOrNull(1).orEmpty() }
)
BOOKMARKS -> TabData(
id = BOOKMARKS,
text = R.string.title_bookmarks,
icon = R.drawable.ic_bookmark_active_24dp,
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) }
)
else -> throw IllegalArgumentException("unknown tab type")
}
}

View File

@ -21,6 +21,7 @@ import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
@ -273,7 +274,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
private fun showSelectListDialog() {
val adapter = ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1)
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
getItem(position)?.let { item -> (view as TextView).text = item.title }
return view
}
}
val statusLayout = LinearLayout(this)
statusLayout.gravity = Gravity.CENTER
@ -371,9 +378,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
val trendingTab = createTabDataFromId(TRENDING)
if (!currentTabs.contains(trendingTab)) {
addableTabs.add(trendingTab)
val trendingTagsTab = createTabDataFromId(TRENDING_TAGS)
if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(trendingTagsTab)
}
val bookmarksTab = createTabDataFromId(BOOKMARKS)
if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(bookmarksTab)
}
addableTabs.add(createTabDataFromId(HASHTAG))

View File

@ -25,10 +25,13 @@ import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.THEME_NIGHT
import com.keylesspalace.tusky.util.setAppNightMode
import com.keylesspalace.tusky.worker.PruneCacheWorker
import com.keylesspalace.tusky.worker.WorkerFactory
@ -76,7 +79,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
AppInjector.init(this)
// Migrate shared preference keys and defaults from version to version.
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION)
if (oldVersion != SCHEMA_VERSION) {
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
}
@ -87,7 +90,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
// init night mode
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
val theme = sharedPreferences.getString(APP_THEME, APP_THEME_DEFAULT)
setAppNightMode(theme)
localeManager.setLocale()
@ -130,6 +133,20 @@ class TuskyApplication : Application(), HasAndroidInjector {
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
}
if (oldVersion < 2023072401) {
// The notifications filter / clear options are shown on a menu, not a separate bar,
// the preference to display them is not needed.
editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER)
}
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
// didn't have an explicit preference set use the previous default, so the
// theme does not unexpectedly change.
if (!sharedPreferences.contains(APP_THEME)) {
editor.putString(APP_THEME, THEME_NIGHT)
}
}
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
editor.apply()
}

View File

@ -59,6 +59,8 @@ import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
@ -67,10 +69,13 @@ import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
import javax.inject.Inject
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
class ViewMediaActivity : BaseActivity(), HasAndroidInjector, ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
@ -337,6 +342,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
shareFile(file, mimeType)
}
override fun androidInjector() = androidInjector
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"

View File

@ -82,11 +82,11 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
}
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].first = newText.toString()
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
}
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].second = newText.toString()
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
}
// Ensure the textview contents are selectable

View File

@ -52,6 +52,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.AttachmentHelper;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
@ -67,6 +68,7 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.NumberFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@ -114,10 +116,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final TextView cardDescription;
private final TextView cardUrl;
private final PollAdapter pollAdapter;
protected LinearLayout filteredPlaceholder;
protected TextView filteredPlaceholderLabel;
protected Button filteredPlaceholderShowButton;
protected ConstraintLayout statusContainer;
protected final LinearLayout filteredPlaceholder;
protected final TextView filteredPlaceholderLabel;
protected final Button filteredPlaceholderShowButton;
protected final ConstraintLayout statusContainer;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
@ -328,14 +330,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
avatarInset.setVisibility(View.VISIBLE);
avatarInset.setBackground(null);
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
statusDisplayOptions.animateAvatars());
statusDisplayOptions.animateAvatars(), null);
avatarRadius = avatarRadius36dp;
}
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
statusDisplayOptions.animateAvatars());
statusDisplayOptions.animateAvatars(),
Collections.singletonList(new CompositeWithOpaqueBackground(avatar)));
}
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
@ -838,9 +840,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
filteredPlaceholderShowButton.setOnClickListener(view -> {
listener.clearWarningAction(getBindingAdapterPosition());
});
filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition()));
}
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {

View File

@ -772,13 +772,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
loadedAccount?.let { loadedAccount ->
val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = getDomain(loadedAccount.url)
if (domain.isEmpty()) {
when {
// If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain)
} else {
if (blockingDomain) {
// If the account is from our own domain, muting it is no-op
domain.isEmpty() || viewModel.isFromOwnDomain -> {
menu.removeItem(R.id.action_mute_domain)
}
blockingDomain -> {
muteDomain.title = getString(R.string.action_unmute_domain, domain)
} else {
}
else -> {
muteDomain.title = getString(R.string.action_mute_domain, domain)
}
}

View File

@ -19,6 +19,7 @@ import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getDomain
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -27,7 +28,7 @@ import javax.inject.Inject
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
accountManager: AccountManager
) : ViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
@ -41,8 +42,13 @@ class AccountViewModel @Inject constructor(
lateinit var accountId: String
var isSelf = false
/** True if the viewed account has the same domain as the active account */
var isFromOwnDomain = false
private var noteUpdateJob: Job? = null
private val activeAccount = accountManager.activeAccount!!
init {
viewModelScope.launch {
eventHub.events.collect { event ->
@ -65,6 +71,8 @@ class AccountViewModel @Inject constructor(
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
isFromOwnDomain = getDomain(account.url) == activeAccount.domain
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
@ -298,7 +306,7 @@ class AccountViewModel @Inject constructor(
fun setAccountInfo(accountId: String) {
this.accountId = accountId
this.isSelf = accountManager.activeAccount?.accountId == accountId
this.isSelf = activeAccount.accountId == accountId
reload(false)
}

View File

@ -82,13 +82,10 @@ class AccountMediaFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
adapter = AccountMediaGridAdapter(
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
useBlurhash = useBlurhash,
context = view.context,
onAttachmentClickListener = ::onAttachmentClick

View File

@ -24,7 +24,6 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
import java.util.Random
class AccountMediaGridAdapter(
private val alwaysShowSensitiveMedia: Boolean,
private val useBlurhash: Boolean,
context: Context,
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
@ -80,7 +79,7 @@ class AccountMediaGridAdapter(
.into(imageView)
imageView.contentDescription = item.attachment.getFormattedDescription(context)
} else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) {
} else if (item.sensitive && !item.isRevealed) {
overlay.show()
overlay.setImageDrawable(mediaHiddenDrawable)

View File

@ -20,6 +20,7 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import retrofit2.HttpException
@ -27,9 +28,9 @@ import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class AccountMediaRemoteMediator(
private val api: MastodonApi,
private val activeAccount: AccountEntity,
private val viewModel: AccountMediaViewModel
) : RemoteMediator<String, AttachmentViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AttachmentViewData>
@ -58,7 +59,7 @@ class AccountMediaRemoteMediator(
}
val attachments = statuses.flatMap { status ->
AttachmentViewData.list(status)
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia ?: false)
}
if (loadType == LoadType.REFRESH) {

View File

@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import javax.inject.Inject
class AccountMediaViewModel @Inject constructor(
private val accountManager: AccountManager,
api: MastodonApi
) : ViewModel() {
@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor(
var currentSource: AccountMediaPagingSource? = null
val activeAccount = accountManager.activeAccount!!
@OptIn(ExperimentalPagingApi::class)
val media = Pager(
config = PagingConfig(
@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor(
currentSource = source
}
},
remoteMediator = AccountMediaRemoteMediator(api, this)
remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this)
).flow
.cachedIn(viewModelScope)

View File

@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
val id: String? = intent.getStringExtra(EXTRA_ID)
val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
}
supportFragmentManager.commit {
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
}
}
@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
companion object {
private const val EXTRA_TYPE = "type"
private const val EXTRA_ID = "id"
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
fun newIntent(context: Context, type: Type, id: String? = null): Intent {
return Intent(context, AccountListActivity::class.java).apply {
putExtra(EXTRA_TYPE, type)
putExtra(EXTRA_ID, id)
putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked)
}
}
}

View File

@ -61,7 +61,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class AccountListFragment :
@ -107,13 +106,15 @@ class AccountListFragment :
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val activeAccount = accountManager.activeAccount!!
adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.FOLLOW_REQUESTS -> {
val headerAdapter = FollowRequestsHeaderAdapter(
instanceName = accountManager.activeAccount!!.domain,
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
instanceName = activeAccount.domain,
accountLocked = activeAccount.locked
)
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
@ -330,7 +331,7 @@ class AccountListFragment :
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(accountList, linkHeader)
} catch (exception: IOException) {
} catch (exception: Exception) {
onFetchAccountsFailure(exception)
}
}
@ -404,14 +405,12 @@ class AccountListFragment :
private const val TAG = "AccountList" // logging tag
private const val ARG_TYPE = "type"
private const val ARG_ID = "id"
private const val ARG_ACCOUNT_LOCKED = "acc_locked"
fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment {
fun newInstance(type: Type, id: String? = null): AccountListFragment {
return AccountListFragment().apply {
arguments = Bundle(3).apply {
putSerializable(ARG_TYPE, type)
putString(ARG_ID, id)
putBoolean(ARG_ACCOUNT_LOCKED, accountLocked)
}
}
}

View File

@ -129,7 +129,7 @@ class AnnouncementsActivity :
is Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.errorMessageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
refreshAnnouncements()
}
binding.errorMessageView.show()

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
@ -95,6 +94,7 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.MentionSpan
import com.keylesspalace.tusky.util.PickMediaFiles
@ -207,24 +207,9 @@ class ComposeActivity :
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
if (notificationId != -1) {
// ComposeActivity was opened from a notification, delete the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
activeAccount = accountManager.activeAccount ?: return
// If started from an intent then compose as the account ID from the intent.
// Otherwise use the active account. If null then the user is not logged in,
// and return from the activity.
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
activeAccount = if (intentAccountId != -1L) {
accountManager.getAccountById(intentAccountId)
} else {
accountManager.activeAccount
} ?: return
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
val theme = preferences.getString(APP_THEME, APP_THEME_DEFAULT)
if (theme == "black") {
setTheme(R.style.TuskyDialogActivityBlackTheme)
}
@ -280,7 +265,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
setupComposeField(preferences, viewModel.startingText)
setupContentWarningField(composeOptions?.contentWarning)
setupPollView()
@ -485,7 +470,12 @@ class ComposeActivity :
if (throwable is UploadServerError) {
displayTransientMessage(throwable.errorMessage)
} else {
displayTransientMessage(R.string.error_media_upload_sending)
displayTransientMessage(
getString(
R.string.error_media_upload_sending_fmt,
throwable.message
)
)
}
}
}
@ -943,7 +933,10 @@ class ComposeActivity :
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
for (i in 0 until content.clip.itemCount) {
pickMedia(content.clip.getItemAt(i).uri)
pickMedia(
content.clip.getItemAt(i).uri,
contentInfo.clip.description.label as String?
)
}
}
return split.second
@ -1064,9 +1057,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun pickMedia(uri: Uri) {
private fun pickMedia(uri: Uri, description: String? = null) {
lifecycleScope.launch {
viewModel.pickMedia(uri).onFailure { throwable ->
viewModel.pickMedia(uri, description).onFailure { throwable ->
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
@ -1347,8 +1340,6 @@ class ComposeActivity :
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
private const val VISIBILITY_KEY = "VISIBILITY"
private const val SCHEDULED_TIME_KEY = "SCHEDULE"
@ -1356,26 +1347,15 @@ class ComposeActivity :
/**
* @param options ComposeOptions to configure the ComposeActivity
* @param notificationId the id of the notification that starts the Activity
* @param accountId the id of the account to compose with, null for the current account
* @return an Intent to start the ComposeActivity
*/
@JvmStatic
@JvmOverloads
fun startIntent(
context: Context,
options: ComposeOptions,
notificationId: Int? = null,
accountId: Long? = null
options: ComposeOptions
): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(COMPOSE_OPTIONS_EXTRA, options)
if (notificationId != null) {
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
}
if (accountId != null) {
putExtra(ACCOUNT_ID_EXTRA, accountId)
}
}
}

View File

@ -39,7 +39,6 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -53,7 +52,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(FlowPreview::class)
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
@ -95,7 +93,7 @@ class ComposeViewModel @Inject constructor(
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
lateinit var composeKind: ComposeKind
private lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null

View File

@ -75,10 +75,6 @@ class AddPollOptionsAdapter(
}
private fun validateInput(): Boolean {
if (options.contains("") || options.distinct().size != options.size) {
return false
}
return true
return !(options.contains("") || options.distinct().size != options.size)
}
}

View File

@ -51,7 +51,7 @@ class CaptionDialog : DialogFragment() {
input = binding.imageDescriptionText
val imageView = binding.imageDescriptionView
imageView.maximumScale = 6f
imageView.maxZoom = 6f
input.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,

View File

@ -57,7 +57,9 @@ class ComposeScheduleView
).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private var scheduleDateTime: Calendar? = null
/** The date/time the user has chosen to schedule the status, in UTC */
private var scheduleDateTimeUtc: Calendar? = null
init {
binding.scheduledDateTime.setOnClickListener { openPickDateDialog() }
@ -71,13 +73,13 @@ class ComposeScheduleView
}
private fun updateScheduleUi() {
if (scheduleDateTime == null) {
if (scheduleDateTimeUtc == null) {
binding.scheduledDateTime.text = ""
binding.invalidScheduleWarning.visibility = GONE
return
}
val scheduled = scheduleDateTime!!.time
val scheduled = scheduleDateTimeUtc!!.time
binding.scheduledDateTime.text = String.format(
"%s %s",
dateFormat.format(scheduled),
@ -98,21 +100,37 @@ class ComposeScheduleView
}
fun resetSchedule() {
scheduleDateTime = null
scheduleDateTimeUtc = null
updateScheduleUi()
}
fun openPickDateDialog() {
val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000
// The earliest point in time the calendar should display. Start with current date/time
val earliest = calendar().apply {
// Add the minimum scheduling interval. This may roll the calendar over to the
// next day (e.g. if the current time is 23:57).
add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS)
// Clear out the time components, so it's midnight
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
val calendarConstraints = CalendarConstraints.Builder()
.setValidator(
DateValidatorPointForward.from(yesterday)
)
.setValidator(DateValidatorPointForward.from(earliest.timeInMillis))
.build()
initializeSuggestedTime()
// Work around a misfeature in MaterialDatePicker. The `selection` is treated as
// millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC
// instead of converting to the user's local timezone.
//
// So we have to add the TZ offset before setting it in the picker
val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis)
val picker = MaterialDatePicker.Builder
.datePicker()
.setSelection(scheduleDateTime!!.timeInMillis)
.setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset)
.setCalendarConstraints(calendarConstraints)
.build()
picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) }
@ -129,11 +147,12 @@ class ComposeScheduleView
private fun openPickTimeDialog() {
val pickerBuilder = MaterialTimePicker.Builder()
scheduleDateTime?.let {
scheduleDateTimeUtc?.let {
pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY])
.setMinute(it[Calendar.MINUTE])
}
pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis))
pickerBuilder.setTimeFormat(getTimeFormat(context))
val picker = pickerBuilder.build()
@ -154,7 +173,7 @@ class ComposeScheduleView
fun setDateTime(scheduledAt: String?) {
val date = getDateTime(scheduledAt) ?: return
initializeSuggestedTime()
scheduleDateTime!!.time = date
scheduleDateTimeUtc!!.time = date
updateScheduleUi()
}
@ -180,24 +199,24 @@ class ComposeScheduleView
// see https://github.com/material-components/material-components-android/issues/882
newDate.timeZone = TimeZone.getTimeZone("UTC")
newDate.timeInMillis = selection
scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
openPickTimeDialog()
}
private fun onTimeSet(hourOfDay: Int, minute: Int) {
initializeSuggestedTime()
scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay)
scheduleDateTime?.set(Calendar.MINUTE, minute)
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
updateScheduleUi()
listener?.onTimeSet(time)
}
val time: String?
get() = scheduleDateTime?.time?.let { iso8601.format(it) }
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
private fun initializeSuggestedTime() {
if (scheduleDateTime == null) {
scheduleDateTime = calendar().apply {
if (scheduleDateTimeUtc == null) {
scheduleDateTimeUtc = calendar().apply {
add(Calendar.MINUTE, 15)
}
}

View File

@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
ImageView avatarView = avatars[i];
if (i < accounts.size()) {
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
avatarRadius48dp, statusDisplayOptions.animateAvatars());
avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
avatarView.setVisibility(View.VISIBLE);
} else {
avatarView.setVisibility(View.GONE);

View File

@ -134,6 +134,7 @@ class ConversationsFragment :
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
binding.statusView.showHelp(R.string.help_empty_conversations)
}
}
is LoadState.Error -> {

View File

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.components.filters
import android.content.Context
import android.content.DialogInterface.BUTTON_POSITIVE
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
@ -81,7 +82,11 @@ class EditFilterActivity : BaseActivity() {
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
binding.filterDeleteButton.setOnClickListener {
lifecycleScope.launch {
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter()
}
}
binding.filterDeleteButton.visible(originalFilter != null)
for (switch in contextSwitches.keys) {

View File

@ -0,0 +1,29 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.filters
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.await
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
.setCancelable(true)
.create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)

View File

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.components.filters
import android.content.DialogInterface.BUTTON_POSITIVE
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
@ -60,18 +61,19 @@ class FiltersActivity : BaseActivity(), FiltersListener {
when (state.loadingState) {
FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide()
FiltersViewModel.LoadingState.ERROR_NETWORK -> {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
loadFilters()
}
binding.messageView.show()
}
FiltersViewModel.LoadingState.ERROR_OTHER -> {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
loadFilters()
}
binding.messageView.show()
}
FiltersViewModel.LoadingState.LOADED -> {
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
if (state.filters.isEmpty()) {
binding.messageView.setup(
R.drawable.elephant_friend_empty,
@ -81,7 +83,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
binding.messageView.show()
} else {
binding.messageView.hide()
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
}
}
}
@ -104,7 +105,11 @@ class FiltersActivity : BaseActivity(), FiltersListener {
}
override fun deleteFilter(filter: Filter) {
viewModel.deleteFilter(filter, binding.root)
lifecycleScope.launch {
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) {
viewModel.deleteFilter(filter, binding.root)
}
}
}
override fun updateFilter(updatedFilter: Filter) {

View File

@ -28,5 +28,6 @@ data class InstanceInfo(
val maxMediaAttachments: Int,
val maxFields: Int,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
val maxFieldValueLength: Int?,
val version: String?
)

View File

@ -99,7 +99,8 @@ class InstanceInfoRepository @Inject constructor(
maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
maxFieldNameLength = instanceInfo?.maxFieldNameLength,
maxFieldValueLength = instanceInfo?.maxFieldValueLength
maxFieldValueLength = instanceInfo?.maxFieldValueLength,
version = instanceInfo?.version
)
}
}

View File

@ -99,7 +99,7 @@ sealed class LoginResult : Parcelable {
data class Err(val errorMessage: String) : LoginResult()
@Parcelize
object Cancel : LoginResult()
data object Cancel : LoginResult()
}
/** Activity to do Oauth process using WebView. */

View File

@ -85,13 +85,6 @@ public class NotificationHelper {
/** Dynamic notification IDs start here */
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
/**
* constants used in Intents
*/
public static final String ACCOUNT_ID = "account_id";
public static final String TYPE = APPLICATION_ID + ".notification.type";
private static final String TAG = "NotificationHelper";
public static final String REPLY_ACTION = "REPLY_ACTION";
@ -245,7 +238,7 @@ public class NotificationHelper {
Bundle extras = new Bundle();
// Add the sending account's name, so it can be used when summarising this notification
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString());
extras.putSerializable(EXTRA_NOTIFICATION_TYPE, body.getType());
builder.addExtras(extras);
// Only alert for the first notification of a batch to avoid multiple alerts at once
@ -285,7 +278,7 @@ public class NotificationHelper {
int accountId = (int) account.getId();
// Initialise the map with all channel IDs.
for (Notification.Type ty : Notification.Type.values()) {
for (Notification.Type ty : Notification.Type.getEntries()) {
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
}
@ -325,11 +318,10 @@ public class NotificationHelper {
// Create a notification that summarises the other notifications in this group
// All notifications in this group have the same type, so get it from the first.
String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE);
Notification.Type notificationType = (Notification.Type)members.get(0).getNotification().extras.getSerializable(EXTRA_NOTIFICATION_TYPE);
Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType);
Intent summaryResultIntent = new Intent(context, MainActivity.class);
summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId);
summaryResultIntent.putExtra(TYPE, notificationType);
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
summaryStackBuilder.addNextIntent(summaryResultIntent);
@ -373,10 +365,8 @@ public class NotificationHelper {
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
// we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class);
eventResultIntent.putExtra(ACCOUNT_ID, account.getId());
eventResultIntent.putExtra(TYPE, body.getType().name());
Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType());
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
eventStackBuilder.addParentStack(MainActivity.class);
eventStackBuilder.addNextIntent(eventResultIntent);
@ -464,12 +454,7 @@ public class NotificationHelper {
composeOptions.setLanguage(actionableStatus.getLanguage());
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
Intent composeIntent = ComposeActivity.startIntent(
context,
composeOptions,
notificationId,
account.getId()
);
Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId());
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

View File

@ -28,7 +28,6 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
@ -46,7 +45,6 @@ import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
@ -123,21 +121,6 @@ class NotificationsFragment :
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
}
private fun updateFilterVisibility(showFilter: Boolean) {
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
if (showFilter) {
binding.appBarOptions.setExpanded(true, false)
binding.appBarOptions.visibility = View.VISIBLE
// Set content behaviour to hide filter on scroll
params.behavior = ScrollingViewBehavior()
} else {
binding.appBarOptions.setExpanded(false, false)
binding.appBarOptions.visibility = View.GONE
// Clear behaviour to hide app bar
params.behavior = null
}
}
private fun confirmClearNotifications() {
AlertDialog.Builder(requireContext())
.setMessage(R.string.notification_clear_text)
@ -215,8 +198,6 @@ class NotificationsFragment :
footer = NotificationsLoadStateAdapter { adapter.retry() }
)
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
binding.buttonFilter.setOnClickListener { showFilterDialog() }
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
false
@ -293,7 +274,7 @@ class NotificationsFragment :
val position = adapter.snapshot().indexOfFirst {
it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id
}
if (position != RecyclerView.NO_POSITION) {
if (position != NO_POSITION) {
adapter.notifyItemChanged(position)
}
}
@ -369,10 +350,10 @@ class NotificationsFragment :
}
}
// Update filter option visibility from uiState
launch {
viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) }
}
// Collect the uiState. Nothing is done with it, but if you don't collect it then
// accessing viewModel.uiState.value (e.g., when the filter dialog is created)
// returns an empty object.
launch { viewModel.uiState.collect() }
// Update status display from statusDisplayOptions. If the new options request
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
@ -418,13 +399,13 @@ class NotificationsFragment :
when ((loadState.refresh as LoadState.Error).error) {
is IOException -> {
binding.statusView.setup(
R.drawable.elephant_offline,
R.drawable.errorphant_offline,
R.string.error_network
) { adapter.retry() }
}
else -> {
binding.statusView.setup(
R.drawable.elephant_error,
R.drawable.errorphant_error,
R.string.error_generic
) { adapter.retry() }
}
@ -439,10 +420,17 @@ class NotificationsFragment :
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_notifications, menu)
val iconColor = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
colorInt = iconColor
}
}
menu.findItem(R.id.action_edit_notification_filter)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_tune).apply {
sizeDp = 20
colorInt = iconColor
}
}
}
@ -458,6 +446,14 @@ class NotificationsFragment :
viewModel.accept(InfallibleUiAction.LoadNewest)
true
}
R.id.action_edit_notification_filter -> {
showFilterDialog()
true
}
R.id.action_clear_notifications -> {
confirmClearNotifications()
true
}
else -> false
}
}
@ -518,7 +514,11 @@ class NotificationsFragment :
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.peek(position)?.statusViewData?.status ?: return
super.viewMedia(attachmentIndex, list(status), view)
super.viewMedia(
attachmentIndex,
list(status, viewModel.statusDisplayOptions.value.showSensitiveMedia),
view
)
}
override fun onViewThread(position: Int) {
@ -621,7 +621,6 @@ class NotificationsFragment :
override fun onReselect() {
if (isAdded) {
binding.appBarOptions.setExpanded(true, false)
layoutManager.scrollToPosition(0)
}
}

View File

@ -124,7 +124,7 @@ class NotificationsPagingAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (NotificationViewKind.values()[viewType]) {
return when (NotificationViewKind.entries[viewType]) {
NotificationViewKind.STATUS -> {
StatusViewHolder(
ItemStatusBinding.inflate(inflater, parent, false),

View File

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -70,29 +69,23 @@ import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
data class UiState(
/** Filtered notification types */
val activeFilter: Set<Notification.Type> = emptySet(),
/** True if the UI to filter and clear notifications should be shown */
val showFilterOptions: Boolean = false,
/** True if the FAB should be shown while scrolling */
val showFabWhileScrolling: Boolean = true
)
/** Preferences the UI reacts to */
data class UiPrefs(
val showFabWhileScrolling: Boolean,
val showFilter: Boolean
val showFabWhileScrolling: Boolean
) {
companion object {
/** Relevant preference keys. Changes to any of these trigger a display update */
val prefKeys = setOf(
PrefKeys.FAB_HIDE,
PrefKeys.SHOW_NOTIFICATIONS_FILTER
PrefKeys.FAB_HIDE
)
}
}
@ -103,7 +96,7 @@ sealed class UiAction
/** Actions the user can trigger from the UI. These actions may fail. */
sealed class FallibleUiAction : UiAction() {
/** Clear all notifications */
object ClearNotifications : FallibleUiAction()
data object ClearNotifications : FallibleUiAction()
}
/**
@ -129,7 +122,7 @@ sealed class InfallibleUiAction : UiAction() {
// Resets the account's `lastNotificationId`, which can't fail, which is why this is
// infallible. Reloading the data may fail, but that's handled by the paging system /
// adapter refresh logic.
object LoadNewest : InfallibleUiAction()
data object LoadNewest : InfallibleUiAction()
}
/** Actions the user can trigger on an individual notification. These may fail. */
@ -146,13 +139,13 @@ sealed class UiSuccess {
// of these three should trigger the UI to refresh.
/** A user was blocked */
object Block : UiSuccess()
data object Block : UiSuccess()
/** A user was muted */
object Mute : UiSuccess()
data object Mute : UiSuccess()
/** A conversation was muted */
object MuteConversation : UiSuccess()
data object MuteConversation : UiSuccess()
}
/** The result of a successful action on a notification */
@ -286,7 +279,7 @@ sealed class UiError(
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
private val preferences: SharedPreferences,
@ -497,7 +490,6 @@ class NotificationsViewModel @Inject constructor(
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
UiState(
activeFilter = filter.filter,
showFilterOptions = prefs.showFilter,
showFabWhileScrolling = prefs.showFabWhileScrolling
)
}.stateIn(
@ -546,8 +538,7 @@ class NotificationsViewModel @Inject constructor(
.onStart { emit(toPrefs()) }
private fun toPrefs() = UiPrefs(
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false),
showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false)
)
companion object {

View File

@ -34,6 +34,7 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.setAppNightMode
@ -145,8 +146,8 @@ class PreferencesActivity :
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"appTheme" -> {
val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT)
APP_THEME -> {
val theme = sharedPreferences.getNonNullString(APP_THEME, APP_THEME_DEFAULT)
Log.d("activeTheme", theme)
setAppNightMode(theme)

View File

@ -208,13 +208,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.SHOW_NOTIFICATIONS_FILTER
setTitle(R.string.pref_title_show_notifications_filter)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.CONFIRM_REBLOGS

View File

@ -19,9 +19,9 @@ import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.checkBoxPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference
class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -29,14 +29,14 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
preferenceCategory(R.string.title_home) { category ->
category.isIconSpaceReserved = false
checkBoxPreference {
switchPreference {
setTitle(R.string.pref_title_show_boosts)
key = PrefKeys.TAB_FILTER_HOME_BOOSTS
setDefaultValue(true)
isIconSpaceReserved = false
}
checkBoxPreference {
switchPreference {
setTitle(R.string.pref_title_show_replies)
key = PrefKeys.TAB_FILTER_HOME_REPLIES
setDefaultValue(true)

View File

@ -40,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, SearchView.OnQueryTextListener {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -91,8 +91,6 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
searchViewMenuItem.expandActionView()
val searchView = searchViewMenuItem.actionView as SearchView
setupSearchView(searchView)
searchView.setQuery(viewModel.currentQuery, false)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
@ -150,9 +148,23 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt()
searchView.maxWidth = pxScreenWidth - pxBuffer
// Keep text that was entered also when switching to a different tab (before the search is executed)
searchView.setOnQueryTextListener(this)
searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false)
searchView.requestFocus()
}
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.currentSearchFieldContent = newText
return false
}
override fun androidInjector() = androidInjector
companion object {

View File

@ -45,6 +45,7 @@ class SearchViewModel @Inject constructor(
) : ViewModel() {
var currentQuery: String = ""
var currentSearchFieldContent: String? = null
val activeAccount: AccountEntity?
get() = accountManager.activeAccount

View File

@ -1,9 +1,10 @@
package com.keylesspalace.tusky.components.timeline.util
import com.google.gson.JsonParseException
import retrofit2.HttpException
import java.io.IOException
fun Throwable.isExpected() = this is IOException || this is HttpException
fun Throwable.isExpected() = this is IOException || this is HttpException || this is JsonParseException
inline fun <T> ifExpected(
t: Throwable,

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.timeline.viewmodel
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
@ -117,6 +118,7 @@ class CachedTimelineRemoteMediator(
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return ifExpected(e) {
Log.w(TAG, "Failed to load timeline", e)
MediatorResult.Error(e)
}
}
@ -175,4 +177,8 @@ class CachedTimelineRemoteMediator(
}
return overlappedStatuses
}
companion object {
private const val TAG = "CachedTimelineRM"
}
}

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.timeline.viewmodel
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
@ -106,8 +107,13 @@ class NetworkTimelineRemoteMediator(
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return ifExpected(e) {
Log.w(TAG, "Failed to load timeline", e)
MediatorResult.Error(e)
}
}
}
companion object {
private const val TAG = "NetworkTimelineRM"
}
}

View File

@ -48,7 +48,7 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector {
if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
supportFragmentManager.commit {
val fragment = TrendingFragment.newInstance()
val fragment = TrendingTagsFragment.newInstance()
replace(R.id.fragmentContainer, fragment)
}
}

View File

@ -24,7 +24,7 @@ import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingAdapter(
class TrendingTagsAdapter(
private val onViewTag: (String) -> Unit
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {

View File

@ -33,8 +33,8 @@ import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingTagsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
@ -48,8 +48,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class TrendingFragment :
Fragment(R.layout.fragment_trending),
class TrendingTagsFragment :
Fragment(R.layout.fragment_trending_tags),
OnRefreshListener,
Injectable,
ReselectableFragment,
@ -58,11 +58,11 @@ class TrendingFragment :
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: TrendingViewModel by viewModels { viewModelFactory }
private val viewModel: TrendingTagsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTrendingBinding::bind)
private val binding by viewBinding(FragmentTrendingTagsBinding::bind)
private val adapter = TrendingAdapter(::onViewTag)
private val adapter = TrendingTagsAdapter(::onViewTag)
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@ -111,8 +111,8 @@ class TrendingFragment :
spanSizeLookup = object : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (adapter.getItemViewType(position)) {
TrendingAdapter.VIEW_TYPE_HEADER -> columnCount
TrendingAdapter.VIEW_TYPE_TAG -> 1
TrendingTagsAdapter.VIEW_TYPE_HEADER -> columnCount
TrendingTagsAdapter.VIEW_TYPE_TAG -> 1
else -> -1
}
}
@ -139,15 +139,15 @@ class TrendingFragment :
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
private fun processViewState(uiState: TrendingTagsViewModel.TrendingTagsUiState) {
Log.d(TAG, uiState.loadingState.name)
when (uiState.loadingState) {
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError()
}
}
@ -194,7 +194,7 @@ class TrendingFragment :
binding.swipeRefreshLayout.isRefreshing = false
binding.messageView.setup(
R.drawable.elephant_offline,
R.drawable.errorphant_offline,
R.string.error_network
) { refreshContent() }
}
@ -206,7 +206,7 @@ class TrendingFragment :
binding.swipeRefreshLayout.isRefreshing = false
binding.messageView.setup(
R.drawable.elephant_error,
R.drawable.errorphant_error,
R.string.error_generic
) { refreshContent() }
}
@ -247,8 +247,8 @@ class TrendingFragment :
}
companion object {
private const val TAG = "TrendingFragment"
private const val TAG = "TrendingTagsFragment"
fun newInstance() = TrendingFragment()
fun newInstance() = TrendingTagsFragment()
}
}

View File

@ -35,7 +35,7 @@ import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class TrendingViewModel @Inject constructor(
class TrendingTagsViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : ViewModel() {
@ -43,13 +43,13 @@ class TrendingViewModel @Inject constructor(
INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER
}
data class TrendingUiState(
data class TrendingTagsUiState(
val trendingViewData: List<TrendingViewData>,
val loadingState: LoadingState
)
val uiState: Flow<TrendingUiState> get() = _uiState
private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL))
val uiState: Flow<TrendingTagsUiState> get() = _uiState
private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL))
init {
invalidate()
@ -73,38 +73,42 @@ class TrendingViewModel @Inject constructor(
*/
fun invalidate(refresh: Boolean = false) = viewModelScope.launch {
if (refresh) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.REFRESHING)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING)
}
val deferredFilters = async { mastodonApi.getFilters() }
mastodonApi.trendingTags().fold(
{ tagResponse ->
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
}
val tags = tagResponse
.filter { tag ->
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
} ?: false
val firstTag = tagResponse.firstOrNull()
_uiState.value = if (firstTag == null) {
TrendingTagsUiState(emptyList(), LoadingState.LOADED)
} else {
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
}
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData()
val tags = tagResponse
.filter { tag ->
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
} ?: false
}
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData()
val firstTag = tagResponse.first()
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
_uiState.value = TrendingUiState(listOf(header) + tags, LoadingState.LOADED)
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED)
}
},
{ error ->
Log.w(TAG, "failed loading trending tags", error)
if (error is IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_NETWORK)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_OTHER)
}
}
)

View File

@ -24,36 +24,34 @@ import androidx.core.view.forEach
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
class ConversationLineItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
private val divider: Drawable = ContextCompat.getDrawable(context, R.drawable.conversation_thread_line)!!
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start)
val dividerEnd = dividerStart + divider.intrinsicWidth
private val avatarTopMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin)
private val halfAvatarHeight = context.resources.getDimensionPixelSize(R.dimen.timeline_status_avatar_height) / 2
private val statusLineMarginStart = context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start)
val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin)
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val dividerStart = parent.paddingStart + statusLineMarginStart
val dividerEnd = dividerStart + divider.intrinsicWidth
val items = (parent.adapter as ThreadAdapter).currentList
parent.forEach { child ->
parent.forEach { statusItemView ->
val position = parent.getChildAdapterPosition(statusItemView)
val position = parent.getChildAdapterPosition(child)
val current = items.getOrNull(position)
if (current != null) {
items.getOrNull(position)?.let { current ->
val above = items.getOrNull(position - 1)
val dividerTop = if (above != null && above.id == current.status.inReplyToId) {
child.top
statusItemView.top
} else {
child.top + avatarMargin
statusItemView.top + avatarTopMargin + halfAvatarHeight
}
val below = items.getOrNull(position + 1)
val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) {
child.bottom
statusItemView.bottom
} else {
child.top + avatarMargin
statusItemView.top + avatarTopMargin + halfAvatarHeight
}
if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) {

View File

@ -336,7 +336,11 @@ class ViewThreadFragment :
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.currentList[position].status
super.viewMedia(attachmentIndex, list(status), view)
super.viewMedia(
attachmentIndex,
list(status, alwaysShowSensitiveMedia),
view
)
}
override fun onViewThread(position: Int) {

View File

@ -516,7 +516,7 @@ class ViewThreadViewModel @Inject constructor(
sealed interface ThreadUiState {
/** The initial load of the detailed status for this thread */
object Loading : ThreadUiState
data object Loading : ThreadUiState
/** Loading the detailed status has completed, now loading ancestors/descendants */
data class LoadingThread(
@ -535,7 +535,7 @@ sealed interface ThreadUiState {
) : ThreadUiState
/** Refreshing the thread with a swipe */
object Refreshing : ThreadUiState
data object Refreshing : ThreadUiState
}
enum class RevealButtonState {

View File

@ -51,10 +51,10 @@ class ViewEditsAdapter(
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
/** Size of large text in this theme, in px */
var largeTextSizePx: Float = 0f
private var largeTextSizePx: Float = 0f
/** Size of medium text in this theme, in px */
var mediumTextSizePx: Float = 0f
private var mediumTextSizePx: Float = 0f
override fun onCreateViewHolder(
parent: ViewGroup,

View File

@ -132,12 +132,12 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
}
sealed interface EditsUiState {
object Initial : EditsUiState
object Loading : EditsUiState
data object Initial : EditsUiState
data object Loading : EditsUiState
// "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success,
// and state flows don't emit repeated states, so the UI never updates.
object Refreshing : EditsUiState
data object Refreshing : EditsUiState
class Error(val throwable: Throwable) : EditsUiState
data class Success(
val edits: List<StatusEdit>

View File

@ -100,7 +100,11 @@ data class AccountEntity(
* ID of the status at the top of the visible list in the home timeline when the
* user navigated away.
*/
var lastVisibleHomeTimelineStatusId: String? = null
var lastVisibleHomeTimelineStatusId: String? = null,
/** true if the connected Mastodon account is locked (has to manually approve all follow requests **/
@ColumnInfo(defaultValue = "0")
var locked: Boolean = false
) {
val identifier: String
@ -125,9 +129,7 @@ data class AccountEntity(
other as AccountEntity
if (id == other.id) return true
if (domain == other.domain && accountId == other.accountId) return true
return false
return domain == other.domain && accountId == other.accountId
}
override fun hashCode(): Int {

View File

@ -156,6 +156,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
it.defaultPostLanguage = account.source?.language.orEmpty()
it.defaultMediaSensitivity = account.source?.sensitive ?: false
it.emojis = account.emojis.orEmpty()
it.locked = account.locked
Log.d(TAG, "updateActiveAccount: saving account with id " + it.id)
accountDao.insertOrReplace(it)

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.db;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.AutoMigration;
import androidx.room.Database;
import androidx.room.DeleteColumn;
@ -41,20 +42,21 @@ import java.io.File;
TimelineAccountEntity.class,
ConversationEntity.class
},
version = 51,
version = 53,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@AutoMigration(from = 50, to = 51)
@AutoMigration(from = 50, to = 51),
@AutoMigration(from = 51, to = 52),
}
)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public abstract DraftDao draftDao();
@NonNull public abstract AccountDao accountDao();
@NonNull public abstract InstanceDao instanceDao();
@NonNull public abstract ConversationsDao conversationDao();
@NonNull public abstract TimelineDao timelineDao();
@NonNull public abstract DraftDao draftDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -386,7 +388,7 @@ public abstract class AppDatabase extends RoomDatabase {
private final File oldDraftDirectory;
public Migration25_26(File oldDraftDirectory) {
public Migration25_26(@Nullable File oldDraftDirectory) {
super(25, 26);
this.oldDraftDirectory = oldDraftDirectory;
}
@ -672,4 +674,15 @@ public abstract class AppDatabase extends RoomDatabase {
@DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications")
static class MIGRATION_49_50 implements AutoMigrationSpec { }
/**
* TabData.TRENDING was renamed to TabData.TRENDING_TAGS, and the text
* representation was changed from "Trending" to "TrendingTags".
*/
public static final Migration MIGRATION_52_53 = new Migration(52, 53) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("UPDATE `AccountEntity` SET `tabpreferences` = REPLACE(tabpreferences, 'Trending:', 'TrendingTags:')");
}
};
}

View File

@ -94,7 +94,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesPreferencesActivity(): PreferencesActivity
@ContributesAndroidInjector
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewMediaActivity(): ViewMediaActivity
@ContributesAndroidInjector

View File

@ -68,7 +68,7 @@ class AppModule {
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44,
AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47,
AppDatabase.MIGRATION_47_48
AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53
)
.build()
}

View File

@ -32,16 +32,13 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.trending.TrendingFragment
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
import com.keylesspalace.tusky.fragment.ViewVideoFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector
/**
* Created by charlag on 3/24/18.
*/
@Module
abstract class FragmentBuildersModule {
@ContributesAndroidInjector
@ -102,5 +99,8 @@ abstract class FragmentBuildersModule {
abstract fun listsForAccountFragment(): ListsForAccountFragment
@ContributesAndroidInjector
abstract fun trendingFragment(): TrendingFragment
abstract fun trendingTagsFragment(): TrendingTagsFragment
@ContributesAndroidInjector
abstract fun viewVideoFragment(): ViewVideoFragment
}

View File

@ -39,7 +39,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel
import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
@ -173,8 +173,8 @@ abstract class ViewModelModule {
@Binds
@IntoMap
@ViewModelKey(TrendingViewModel::class)
internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel
@ViewModelKey(TrendingTagsViewModel::class)
internal abstract fun trendingTagsViewModel(viewModel: TrendingTagsViewModel): ViewModel
@Binds
@IntoMap

View File

@ -22,5 +22,6 @@ package com.keylesspalace.tusky.entity
data class MastoList(
val id: String,
val title: String
val title: String,
val exclusive: Boolean?
)

View File

@ -19,25 +19,31 @@ import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PointF
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.os.BundleCompat
import androidx.core.view.GestureDetectorCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.github.chrisbanes.photoview.PhotoViewAttacher
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentViewImageBinding
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.ortiz.touchview.OnTouchCoordinatesListener
import com.ortiz.touchview.TouchImageView
import io.reactivex.rxjava3.subjects.BehaviorSubject
import kotlin.math.abs
@ -48,10 +54,8 @@ class ViewImageFragment : ViewMediaFragment() {
fun onPhotoTap()
}
private var _binding: FragmentViewImageBinding? = null
private val binding get() = _binding!!
private val binding by viewBinding(FragmentViewImageBinding::bind)
private lateinit var attacher: PhotoViewAttacher
private lateinit var photoActionsListener: PhotoActionsListener
private lateinit var toolbar: View
private var transition = BehaviorSubject.create<Unit>()
@ -84,8 +88,7 @@ class ViewImageFragment : ViewMediaFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = (requireActivity() as ViewMediaActivity).toolbar
this.transition = BehaviorSubject.create()
_binding = FragmentViewImageBinding.inflate(inflater, container, false)
return binding.root
return inflater.inflate(R.layout.fragment_view_image, container, false)
}
@SuppressLint("ClickableViewAccessibility")
@ -108,95 +111,139 @@ class ViewImageFragment : ViewMediaFragment() {
}
}
attacher = PhotoViewAttacher(binding.photoView).apply {
// This prevents conflicts with ViewPager
setAllowParentInterceptOnEdge(true)
val singleTapDetector = GestureDetectorCompat(
requireContext(),
object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent) = true
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
photoActionsListener.onPhotoTap()
return false
}
}
)
// Clicking outside the photo closes the viewer.
setOnOutsidePhotoTapListener { photoActionsListener.onDismiss() }
setOnClickListener { onMediaTap() }
binding.photoView.setOnTouchCoordinatesListener(object : OnTouchCoordinatesListener {
/** Y coordinate of the last single-finger drag */
var lastDragY: Float? = null
/* A vertical swipe motion also closes the viewer. This is especially useful when the photo
* mostly fills the screen so clicking outside is difficult. */
setOnSingleFlingListener { _, _, velocityX, velocityY ->
var result = false
if (abs(velocityY) > abs(velocityX)) {
override fun onTouchCoordinate(view: View, event: MotionEvent, bitmapPoint: PointF) {
singleTapDetector.onTouchEvent(event)
// Two fingers have gone down after a single finger drag. Finish the drag
if (event.pointerCount == 2 && lastDragY != null) {
onGestureEnd(view)
lastDragY = null
}
// Stop the parent view from handling touches if either (a) the user has 2+
// fingers on the screen, or (b) the image has been zoomed in, and can be scrolled
// horizontally in both directions.
//
// This stops things like ViewPager2 from trying to intercept a left/right swipe
// and ensures that the image does not appear to "stick" to the screen as different
// views fight over who should be handling the swipe.
//
// If the view can be scrolled in one direction it's OK to let the parent intercept,
// which allows the user to swipe between images even if one or more of them have
// been zoomed in.
if (event.pointerCount >= 2 || view.canScrollHorizontally(1) && view.canScrollHorizontally(-1)) {
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
view.parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_UP -> {
view.parent.requestDisallowInterceptTouchEvent(false)
}
}
return
}
// The user is dragging the image around
if (event.pointerCount == 1) {
// If the image is zoomed then the swipe-to-dismiss functionality is disabled
if ((view as TouchImageView).isZoomed) return
// The user's finger just went down, start recording where they are dragging from
if (event.action == MotionEvent.ACTION_DOWN) {
lastDragY = event.rawY
return
}
// The user is dragging the un-zoomed image to possibly fling it up or down
// to dismiss.
if (event.action == MotionEvent.ACTION_MOVE) {
// lastDragY may be null; e.g., the user was performing a two-finger drag,
// and has lifted one finger. In this case do nothing
lastDragY ?: return
// Compute the Y offset of the drag, and scale/translate the photoview
// accordingly.
val diff = event.rawY - lastDragY!!
if (view.translationY != 0f || abs(diff) > 40) {
// Drag has definitely started, stop the parent from interfering
view.parent.requestDisallowInterceptTouchEvent(true)
view.translationY += diff
val scale = (-abs(view.translationY) / 720 + 1).coerceAtLeast(0.5f)
view.scaleY = scale
view.scaleX = scale
lastDragY = event.rawY
}
return
}
// The user has finished dragging. Allow the parent to handle touch events if
// appropriate, and end the gesture.
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
view.parent.requestDisallowInterceptTouchEvent(false)
if (lastDragY != null) onGestureEnd(view)
lastDragY = null
return
}
}
}
/**
* Handle the end of the user's gesture.
*
* If the user was previously dragging, and the image has been dragged a sufficient
* distance then we are done. Otherwise, animate the image back to its starting position.
*/
private fun onGestureEnd(view: View) {
if (abs(view.translationY) > 180) {
photoActionsListener.onDismiss()
result = true
} else {
view.animate().translationY(0f).scaleX(1f).start()
}
result
}
}
var lastY = 0f
binding.photoView.setOnTouchListener { v, event ->
// This part is for scaling/translating on vertical move.
// We use raw coordinates to get the correct ones during scaling
if (event.action == MotionEvent.ACTION_DOWN) {
lastY = event.rawY
} else if (event.pointerCount == 1 &&
attacher.scale == 1f &&
event.action == MotionEvent.ACTION_MOVE
) {
val diff = event.rawY - lastY
// This code is to prevent transformations during page scrolling
// If we are already translating or we reached the threshold, then transform.
if (binding.photoView.translationY != 0f || abs(diff) > 40) {
binding.photoView.translationY += (diff)
val scale = (-abs(binding.photoView.translationY) / 720 + 1).coerceAtLeast(0.5f)
binding.photoView.scaleY = scale
binding.photoView.scaleX = scale
lastY = event.rawY
return@setOnTouchListener true
}
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
onGestureEnd()
}
attacher.onTouch(v, event)
}
})
finalizeViewSetup(url, attachment?.previewUrl, description)
}
private fun onGestureEnd() {
if (_binding == null) {
return
}
if (abs(binding.photoView.translationY) > 180) {
photoActionsListener.onDismiss()
} else {
binding.photoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start()
}
}
private fun onMediaTap() {
photoActionsListener.onPhotoTap()
}
override fun onToolbarVisibilityChange(visible: Boolean) {
if (_binding == null || !userVisibleHint) {
return
}
if (!userVisibleHint) return
isDescriptionVisible = showingDescription && visible
val alpha = if (isDescriptionVisible) 1.0f else 0.0f
binding.captionSheet.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
if (_binding != null) {
binding.captionSheet.visible(isDescriptionVisible)
}
view ?: return
binding.captionSheet.visible(isDescriptionVisible)
animation.removeListener(this)
}
})
.start()
}
override fun onDestroyView() {
override fun onStop() {
super.onStop()
Glide.with(this).clear(binding.photoView)
}
override fun onDestroyView() {
transition.onComplete()
_binding = null
super.onDestroyView()
}
@ -270,7 +317,7 @@ class ViewImageFragment : ViewMediaFragment() {
photoActionsListener.onBringUp()
}
// Hide progress bar only on fail request from internet
if (!isCacheRequest && _binding != null) binding.progressBar.hide()
if (!isCacheRequest) binding.progressBar.hide()
// We don't want to overwrite preview with null when main image fails to load
return !isCacheRequest
}
@ -283,9 +330,7 @@ class ViewImageFragment : ViewMediaFragment() {
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
if (_binding != null) {
binding.progressBar.hide() // Always hide the progress bar on success
}
binding.progressBar.hide() // Always hide the progress bar on success
if (!startedTransition || !shouldStartTransition) {
// Set this right away so that we don't have to concurrent post() requests
@ -303,10 +348,6 @@ class ViewImageFragment : ViewMediaFragment() {
.take(1)
.subscribe {
target.onResourceReady(resource, null)
// It's needed. Don't ask why, I don't know, setImageDrawable() should
// do it by itself but somehow it doesn't work automatically.
// Just do it. If you don't, image will jump around when touched.
attacher.update()
}
}
return true

View File

@ -17,7 +17,9 @@ package com.keylesspalace.tusky.fragment
import android.os.Bundle
import android.text.TextUtils
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment
@ -47,6 +49,7 @@ abstract class ViewMediaFragment : Fragment() {
protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl"
@JvmStatic
@OptIn(UnstableApi::class)
fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment {
val arguments = Bundle(2)
arguments.putParcelable(ARG_ATTACHMENT, attachment)

View File

@ -19,33 +19,60 @@ import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.method.ScrollingMovementMethod
import android.view.GestureDetector
import android.view.KeyEvent
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.MediaController
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.OptIn
import androidx.core.view.GestureDetectorCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.util.EventLogger
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerControlView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView
import okhttp3.OkHttpClient
import javax.inject.Inject
import kotlin.math.abs
class ViewVideoFragment : ViewMediaFragment() {
@UnstableApi
class ViewVideoFragment : ViewMediaFragment(), Injectable {
interface VideoActionsListener {
fun onDismiss()
}
private var _binding: FragmentViewVideoBinding? = null
private val binding get() = _binding!!
@Inject
lateinit var okHttpClient: OkHttpClient
private val binding by viewBinding(FragmentViewVideoBinding::bind)
private lateinit var videoActionsListener: VideoActionsListener
private lateinit var toolbar: View
@ -54,39 +81,266 @@ class ViewVideoFragment : ViewMediaFragment() {
// Hoist toolbar hiding to activity so it can track state across different fragments
// This is explicitly stored as runnable so that we pass it to the handler later for cancellation
mediaActivity.onPhotoTap()
mediaController.hide()
}
private lateinit var mediaActivity: ViewMediaActivity
private lateinit var mediaController: MediaController
private lateinit var mediaPlayerListener: Player.Listener
private var isAudio = false
companion object {
private const val TOOLBAR_HIDE_DELAY_MS = 3000L
}
private lateinit var mediaAttachment: Attachment
private var player: ExoPlayer? = null
/** The saved seek position, if the fragment is being resumed */
private var savedSeekPosition: Long = 0
private lateinit var mediaSourceFactory: DefaultMediaSourceFactory
override fun onAttach(context: Context) {
super.onAttach(context)
mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)))
videoActionsListener = context as VideoActionsListener
}
@SuppressLint("PrivateResource", "MissingInflatedId")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
mediaActivity = activity as ViewMediaActivity
toolbar = mediaActivity.toolbar
val rootView = inflater.inflate(R.layout.fragment_view_video, container, false)
// Move the controls to the bottom of the screen, with enough bottom margin to clear the seekbar
val controls = rootView.findViewById<LinearLayout>(androidx.media3.ui.R.id.exo_center_controls)
val layoutParams = controls.layoutParams as FrameLayout.LayoutParams
layoutParams.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM
layoutParams.bottomMargin = rootView.context.resources.getDimension(androidx.media3.ui.R.dimen.exo_styled_bottom_bar_height)
.toInt()
controls.layoutParams = layoutParams
return rootView
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val attachment = arguments?.getParcelable<Attachment>(ARG_ATTACHMENT)
?: throw IllegalArgumentException("attachment has to be set")
val url = attachment.url
isAudio = attachment.type == Attachment.Type.AUDIO
/**
* Handle single taps, flings, and dragging
*/
val touchListener = object : View.OnTouchListener {
var lastY = 0f
/** The view that contains the playing content */
// binding.videoView is fullscreen, and includes the controls, so don't use that
// when scaling in response to the user dragging on the screen
val contentFrame = binding.videoView.findViewById<AspectRatioFrameLayout>(androidx.media3.ui.R.id.exo_content_frame)
/** Handle taps and flings */
val simpleGestureDetector = GestureDetectorCompat(
requireContext(),
object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent) = true
/** A single tap should show/hide the media description */
override fun onSingleTapUp(e: MotionEvent): Boolean {
mediaActivity.onPhotoTap()
return false
}
/** A fling up/down should dismiss the fragment */
override fun onFling(
e1: MotionEvent,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
if (abs(velocityY) > abs(velocityX)) {
videoActionsListener.onDismiss()
return true
}
return false
}
}
)
@SuppressLint("ClickableViewAccessibility")
override fun onTouch(v: View?, event: MotionEvent): Boolean {
// Track movement, and scale / translate the video display accordingly
if (event.action == MotionEvent.ACTION_DOWN) {
lastY = event.rawY
} else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) {
val diff = event.rawY - lastY
if (contentFrame.translationY != 0f || abs(diff) > 40) {
contentFrame.translationY += diff
val scale = (-abs(contentFrame.translationY) / 720 + 1).coerceAtLeast(0.5f)
contentFrame.scaleY = scale
contentFrame.scaleX = scale
lastY = event.rawY
}
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
if (abs(contentFrame.translationY) > 180) {
videoActionsListener.onDismiss()
} else {
contentFrame.animate().translationY(0f).scaleX(1f).scaleY(1f).start()
}
}
simpleGestureDetector.onTouchEvent(event)
// Allow the player's normal onTouch handler to run as well (e.g., to show the
// player controls on tap)
return false
}
}
mediaPlayerListener = object : Player.Listener {
@SuppressLint("ClickableViewAccessibility", "SyntheticAccessor")
@OptIn(UnstableApi::class)
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
// Wait until the media is loaded before accepting taps as we don't want toolbar to
// be hidden until then.
binding.videoView.setOnTouchListener(touchListener)
binding.progressBar.hide()
binding.videoView.useController = true
binding.videoView.showController()
}
else -> { /* do nothing */ }
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isAudio) return
if (isPlaying) {
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
} else {
handler.removeCallbacks(hideToolbar)
}
}
@SuppressLint("SyntheticAccessor")
override fun onPlayerError(error: PlaybackException) {
binding.progressBar.hide()
val message = getString(
R.string.error_media_playback,
error.cause?.message ?: error.message
)
Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE)
.setTextMaxLines(10)
.setAction(R.string.action_retry) { player?.prepare() }
.show()
}
}
savedSeekPosition = savedInstanceState?.getLong(SEEK_POSITION) ?: 0
mediaAttachment = attachment
finalizeViewSetup(url, attachment.previewUrl, attachment.description)
}
override fun onStart() {
super.onStart()
if (Build.VERSION.SDK_INT > 23) {
initializePlayer()
binding.videoView.onResume()
}
}
override fun onResume() {
super.onResume()
if (_binding != null) {
if (Build.VERSION.SDK_INT <= 23 || player == null) {
initializePlayer()
if (mediaActivity.isToolbarVisible && !isAudio) {
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
}
binding.videoView.start()
binding.videoView.onResume()
}
}
private fun releasePlayer() {
player?.let {
savedSeekPosition = it.currentPosition
it.release()
player = null
binding.videoView.player = null
}
}
override fun onPause() {
super.onPause()
if (_binding != null) {
// If <= API 23 then multi-window mode is not available, so this is a good time to
// pause everything
if (Build.VERSION.SDK_INT <= 23) {
binding.videoView.onPause()
releasePlayer()
handler.removeCallbacks(hideToolbar)
binding.videoView.pause()
mediaController.hide()
}
}
override fun onStop() {
super.onStop()
// If > API 23 then this might be multi-window, and definitely wasn't paused in onPause,
// so pause everything now.
if (Build.VERSION.SDK_INT > 23) {
binding.videoView.onPause()
releasePlayer()
handler.removeCallbacks(hideToolbar)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong(SEEK_POSITION, savedSeekPosition)
}
private fun initializePlayer() {
ExoPlayer.Builder(requireContext())
.setMediaSourceFactory(mediaSourceFactory)
.build().apply {
if (BuildConfig.DEBUG) addAnalyticsListener(EventLogger("$TAG:ExoPlayer"))
setMediaItem(MediaItem.fromUri(mediaAttachment.url))
addListener(mediaPlayerListener)
repeatMode = Player.REPEAT_MODE_ONE
playWhenReady = true
seekTo(savedSeekPosition)
prepare()
player = this
}
binding.videoView.player = player
// Audio-only files might have a preview image. If they do, set it as the artwork
if (isAudio) {
mediaAttachment.previewUrl?.let { url ->
Glide.with(this).load(url).into(object : CustomTarget<Drawable>() {
@SuppressLint("SyntheticAccessor")
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
view ?: return
binding.videoView.defaultArtwork = resource
}
@SuppressLint("SyntheticAccessor")
override fun onLoadCleared(placeholder: Drawable?) {
view ?: return
binding.videoView.defaultArtwork = null
}
})
}
}
}
@ -105,153 +359,20 @@ class ViewVideoFragment : ViewMediaFragment() {
binding.mediaDescription.elevation = binding.videoView.elevation + 1
binding.videoView.transitionName = url
binding.videoView.setVideoPath(url)
mediaController = object : MediaController(mediaActivity) {
override fun show(timeout: Int) {
// We're doing manual auto-close management.
// Also, take focus back from the pause button so we can use the back button.
super.show(0)
mediaController.requestFocus()
}
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event?.keyCode == KeyEvent.KEYCODE_BACK) {
if (event.action == KeyEvent.ACTION_UP) {
hide()
activity?.supportFinishAfterTransition()
}
return true
}
return super.dispatchKeyEvent(event)
}
}
mediaController.setMediaPlayer(binding.videoView)
binding.videoView.setMediaController(mediaController)
binding.videoView.requestFocus()
binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener {
override fun onPlay() {
if (!isAudio) {
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
}
}
override fun onPause() {
if (!isAudio) {
handler.removeCallbacks(hideToolbar)
}
}
})
binding.videoView.setOnPreparedListener { mp ->
val containerWidth = binding.videoContainer.measuredWidth.toFloat()
val containerHeight = binding.videoContainer.measuredHeight.toFloat()
val videoWidth = mp.videoWidth.toFloat()
val videoHeight = mp.videoHeight.toFloat()
if (isAudio) {
binding.videoView.layoutParams.height = 1
binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
} else if (containerWidth / containerHeight > videoWidth / videoHeight) {
binding.videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
binding.videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT
} else {
binding.videoView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
}
// Wait until the media is loaded before accepting taps as we don't want toolbar to
// be hidden until then.
binding.videoView.setOnTouchListener { _, _ ->
mediaActivity.onPhotoTap()
false
}
// Audio doesn't cause the controller to show automatically
if (isAudio) {
mediaController.show()
}
binding.progressBar.hide()
mp.isLooping = true
}
if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) {
mediaActivity.onBringUp()
}
}
private fun hideToolbarAfterDelay(delayMilliseconds: Long) {
handler.postDelayed(hideToolbar, delayMilliseconds)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
mediaActivity = activity as ViewMediaActivity
toolbar = mediaActivity.toolbar
_binding = FragmentViewVideoBinding.inflate(inflater, container, false)
return binding.root
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val attachment = arguments?.getParcelable<Attachment>(ARG_ATTACHMENT)
?: throw IllegalArgumentException("attachment has to be set")
val url = attachment.url
isAudio = attachment.type == Attachment.Type.AUDIO
val gestureDetector = GestureDetectorCompat(
requireContext(),
object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(event: MotionEvent): Boolean {
return true
}
override fun onFling(
e1: MotionEvent,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
if (abs(velocityY) > abs(velocityX)) {
videoActionsListener.onDismiss()
return true
}
return false
}
}
)
var lastY = 0f
binding.root.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
lastY = event.rawY
} else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) {
val diff = event.rawY - lastY
if (binding.videoView.translationY != 0f || abs(diff) > 40) {
binding.videoView.translationY += diff
val scale = (-abs(binding.videoView.translationY) / 720 + 1).coerceAtLeast(0.5f)
binding.videoView.scaleY = scale
binding.videoView.scaleX = scale
lastY = event.rawY
return@setOnTouchListener true
}
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
if (abs(binding.videoView.translationY) > 180) {
videoActionsListener.onDismiss()
} else {
binding.videoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start()
}
}
gestureDetector.onTouchEvent(event)
}
finalizeViewSetup(url, attachment.previewUrl, attachment.description)
private fun hideToolbarAfterDelay(delayMilliseconds: Int) {
handler.postDelayed(hideToolbar, delayMilliseconds.toLong())
}
override fun onToolbarVisibilityChange(visible: Boolean) {
if (_binding == null || !userVisibleHint) {
if (!userVisibleHint) {
return
}
@ -265,27 +386,27 @@ class ViewVideoFragment : ViewMediaFragment() {
binding.mediaDescription.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
@SuppressLint("SyntheticAccessor")
override fun onAnimationEnd(animation: Animator) {
if (_binding != null) {
binding.mediaDescription.visible(isDescriptionVisible)
}
view ?: return
binding.mediaDescription.visible(isDescriptionVisible)
animation.removeListener(this)
}
})
.start()
if (visible && binding.videoView.isPlaying && !isAudio) {
if (visible && (binding.videoView.player?.isPlaying == true) && !isAudio) {
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
} else {
handler.removeCallbacks(hideToolbar)
}
}
override fun onTransitionEnd() {
}
override fun onTransitionEnd() { }
override fun onDestroyView() {
super.onDestroyView()
_binding = null
companion object {
private const val TAG = "ViewVideoFragment"
private const val TOOLBAR_HIDE_DELAY_MS = PlayerControlView.DEFAULT_SHOW_TIMEOUT_MS
private const val SEEK_POSITION = "seekPosition"
}
}

View File

@ -89,6 +89,11 @@ interface MastodonApi {
@GET("api/v1/filters")
suspend fun getFiltersV1(): NetworkResult<List<FilterV1>>
@GET("api/v2/filters/{filterId}")
suspend fun getFilter(
@Path("filterId") filterId: String
): NetworkResult<Filter>
@GET("api/v2/filters")
suspend fun getFilters(): NetworkResult<List<Filter>>
@ -538,14 +543,16 @@ interface MastodonApi {
@FormUrlEncoded
@POST("api/v1/lists")
suspend fun createList(
@Field("title") title: String
@Field("title") title: String,
@Field("exclusive") exclusive: Boolean?
): NetworkResult<MastoList>
@FormUrlEncoded
@PUT("api/v1/lists/{listId}")
suspend fun updateList(
@Path("listId") listId: String,
@Field("title") title: String
@Field("title") title: String,
@Field("exclusive") exclusive: Boolean?
): NetworkResult<MastoList>
@DELETE("api/v1/lists/{listId}")

View File

@ -379,9 +379,7 @@ class SendStatusService : Service(), Injectable {
accountId: Long,
statusId: Int
): Notification {
val intent = Intent(this, MainActivity::class.java)
intent.putExtra(NotificationHelper.ACCOUNT_ID, accountId)
intent.putExtra(MainActivity.OPEN_DRAFTS, true)
val intent = MainActivity.draftIntent(this, accountId)
val pendingIntent = PendingIntent.getActivity(
this,

View File

@ -19,6 +19,7 @@ import android.annotation.TargetApi
import android.content.Intent
import android.service.quicksettings.TileService
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
/**
* Small Addition that adds in a QuickSettings tile
@ -29,11 +30,8 @@ import com.keylesspalace.tusky.MainActivity
class TuskyTileService : TileService() {
override fun onClick() {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
action = Intent.ACTION_SEND
type = "text/plain"
}
val intent = MainActivity.composeIntent(this, ComposeActivity.ComposeOptions())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivityAndCollapse(intent)
}
}

View File

@ -41,7 +41,10 @@ enum class AppTheme(val value: String) {
*
* - Adding a new preference that does not change the interpretation of an existing preference
*/
const val SCHEMA_VERSION = 2023022701
const val SCHEMA_VERSION = 2023082301
/** The schema version for fresh installs */
const val NEW_INSTALL_SCHEMA_VERSION = 0
object PrefKeys {
// Note: not all of these keys are actually used as SharedPreferences keys but we must give
@ -61,7 +64,6 @@ object PrefKeys {
const val ANIMATE_GIF_AVATARS = "animateGifAvatars"
const val USE_BLURHASH = "useBlurhash"
const val SHOW_SELF_USERNAME = "showSelfUsername"
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines"
const val CONFIRM_REBLOGS = "confirmReblogs"
const val CONFIRM_FAVOURITES = "confirmFavourites"
@ -104,4 +106,9 @@ object PrefKeys {
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
/** Keys that are no longer used (e.g., the preference has been removed */
object Deprecated {
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
}
}

View File

@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultRegistryOwner
import androidx.annotation.StringRes
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.LifecycleOwner
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
@ -86,15 +85,6 @@ inline fun PreferenceParent.validatedEditTextPreference(
return pref
}
inline fun PreferenceParent.checkBoxPreference(
builder: CheckBoxPreference.() -> Unit
): CheckBoxPreference {
val pref = CheckBoxPreference(context)
builder(pref)
addPref(pref)
return pref
}
inline fun PreferenceParent.preferenceCategory(
@StringRes title: Int? = null,
builder: PreferenceParent.(PreferenceCategory) -> Unit

View File

@ -0,0 +1,63 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.util
import android.content.DialogInterface
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
/**
* Wait for the alert dialog buttons to be clicked, return the ID of the clicked button
*
* @param positiveText Text to show on the positive button
* @param negativeText Optional text to show on the negative button
* @param neutralText Optional text to show on the neutral button
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun AlertDialog.await(
positiveText: String,
negativeText: String? = null,
neutralText: String? = null
) = suspendCancellableCoroutine<Int> { cont ->
val listener = DialogInterface.OnClickListener { _, which ->
cont.resume(which) { dismiss() }
}
setButton(AlertDialog.BUTTON_POSITIVE, positiveText, listener)
negativeText?.let { setButton(AlertDialog.BUTTON_NEGATIVE, it, listener) }
neutralText?.let { setButton(AlertDialog.BUTTON_NEUTRAL, it, listener) }
setOnCancelListener { cont.cancel() }
cont.invokeOnCancellation { dismiss() }
show()
}
/**
* @see [AlertDialog.await]
*/
suspend fun AlertDialog.await(
@StringRes positiveTextResource: Int,
@StringRes negativeTextResource: Int? = null,
@StringRes neutralTextResource: Int? = null
) = await(
context.getString(positiveTextResource),
negativeTextResource?.let { context.getString(it) },
neutralTextResource?.let { context.getString(it) }
)

View File

@ -0,0 +1,166 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.util
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.View
import androidx.annotation.AttrRes
import androidx.core.content.ContextCompat
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import com.bumptech.glide.util.Util
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.security.MessageDigest
/**
* Set an opaque background behind the non-transparent areas of a bitmap.
*
* Profile images may have areas that are partially transparent (i.e., alpha value >= 1 and < 255).
*
* Displaying those can be a problem if there is anything drawn under them, as it will show
* through the image.
*
* Fix this, by:
*
* - Creating a mask that matches the partially transparent areas of the image
* - Creating a new bitmap that, in the areas that match the mask, contains the same background
* drawable as the [ImageView].
* - Composite the original image over the top
*
* So the partially transparent areas on the original image are composited over the original
* background, the fully transparent areas on the original image are left transparent.
*/
class CompositeWithOpaqueBackground(val view: View) : BitmapTransformation() {
override fun equals(other: Any?): Boolean {
if (other is CompositeWithOpaqueBackground) {
return other.view == view
}
return false
}
override fun hashCode() = Util.hashCode(ID.hashCode(), view.hashCode())
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID_BYTES)
messageDigest.update(ByteBuffer.allocate(4).putInt(view.hashCode()).array())
}
override fun transform(
pool: BitmapPool,
toTransform: Bitmap,
outWidth: Int,
outHeight: Int
): Bitmap {
// If the input bitmap has no alpha channel then there's nothing to do
if (!toTransform.hasAlpha()) return toTransform
// Get the background drawable for this view, falling back to the given attribute
val backgroundDrawable = view.getFirstNonNullBackgroundOrAttr(android.R.attr.colorBackground)
backgroundDrawable ?: return toTransform
// Convert the background to a bitmap.
val backgroundBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
when (backgroundDrawable) {
is ColorDrawable -> backgroundBitmap.eraseColor(backgroundDrawable.color)
else -> {
val backgroundCanvas = Canvas(backgroundBitmap)
backgroundDrawable.setBounds(0, 0, outWidth, outHeight)
backgroundDrawable.draw(backgroundCanvas)
}
}
// Convert the alphaBitmap (where the alpha channel has 8bpp) to a mask of 1bpp
// TODO: toTransform.extractAlpha(paint, ...) could be used here, but I can't find any
// useful documentation covering paints and mask filters.
val maskBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ALPHA_8).apply {
val canvas = Canvas(this)
canvas.drawBitmap(toTransform, 0f, 0f, EXTRACT_MASK_PAINT)
}
val shader = BitmapShader(backgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
val paintShader = Paint()
paintShader.isAntiAlias = true
paintShader.shader = shader
paintShader.style = Paint.Style.FILL_AND_STROKE
// Write the background to a new bitmap, masked to just the non-transparent areas of the
// original image
val dest = pool.get(outWidth, outHeight, toTransform.config)
val canvas = Canvas(dest)
canvas.drawBitmap(maskBitmap, 0f, 0f, paintShader)
// Finally, write the original bitmap over the top
canvas.drawBitmap(toTransform, 0f, 0f, null)
// Clean up intermediate bitmaps
pool.put(maskBitmap)
pool.put(backgroundBitmap)
return dest
}
companion object {
@Suppress("unused")
private const val TAG = "CompositeWithOpaqueBackground"
private val ID = CompositeWithOpaqueBackground::class.qualifiedName!!
private val ID_BYTES = ID.toByteArray(Charset.forName("UTF-8"))
/** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */
private val EXTRACT_MASK_PAINT = Paint().apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix(
floatArrayOf(
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 255f, 0f
)
)
)
isAntiAlias = false
}
/**
* @param attr attribute reference for the default drawable if no background is set on
* this view or any of its ancestors.
* @return The first non-null background drawable from this view, or its ancestors,
* falling back to the attribute resource given by `attr` if none of the views have a
* background.
*/
fun View.getFirstNonNullBackgroundOrAttr(@AttrRes attr: Int): Drawable? =
background ?: (parent as? View)?.getFirstNonNullBackgroundOrAttr(attr) ?: run {
val v = TypedValue()
context.theme.resolveAttribute(attr, v, true)
// TODO: On API 29 can use v.isColorType here
if (v.type >= TypedValue.TYPE_FIRST_COLOR_INT && v.type <= TypedValue.TYPE_LAST_COLOR_INT) {
ColorDrawable(v.data)
} else {
ContextCompat.getDrawable(context, v.resourceId)
}
}
}
}

View File

@ -20,7 +20,6 @@ package com.keylesspalace.tusky.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.TimeMark
import kotlin.time.TimeSource
@ -54,7 +53,6 @@ import kotlin.time.TimeSource
* @param timeout Emissions within this duration of the last emission are filtered
* @param timeSource Used to measure elapsed time. Normally only overridden in tests
*/
@OptIn(ExperimentalTime::class)
fun <T> Flow<T>.throttleFirst(
timeout: Duration,
timeSource: TimeSource = TimeSource.Monotonic

View File

@ -3,39 +3,50 @@
package com.keylesspalace.tusky.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.widget.ImageView
import androidx.annotation.Px
import com.bumptech.glide.Glide
import com.bumptech.glide.load.MultiTransformation
import com.bumptech.glide.load.Transformation
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.keylesspalace.tusky.R
private val centerCropTransformation = CenterCrop()
fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) {
fun loadAvatar(
url: String?,
imageView: ImageView,
@Px radius: Int,
animate: Boolean,
transforms: List<Transformation<Bitmap>>? = null
) {
if (url.isNullOrBlank()) {
Glide.with(imageView)
.load(R.drawable.avatar_default)
.into(imageView)
} else {
val multiTransformation = MultiTransformation(
buildList {
transforms?.let { this.addAll(it) }
add(centerCropTransformation)
add(RoundedCorners(radius))
}
)
if (animate) {
Glide.with(imageView)
.load(url)
.transform(
centerCropTransformation,
RoundedCorners(radius)
)
.transform(multiTransformation)
.placeholder(R.drawable.avatar_default)
.into(imageView)
} else {
Glide.with(imageView)
.asBitmap()
.load(url)
.transform(
centerCropTransformation,
RoundedCorners(radius)
)
.transform(multiTransformation)
.placeholder(R.drawable.avatar_default)
.into(imageView)
}

View File

@ -68,7 +68,7 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List<Menti
val spannableContent = markupHiddenUrls(view.context, content)
view.text = spannableContent.apply {
getSpans(0, content.length, URLSpan::class.java).forEach {
getSpans(0, spannableContent.length, URLSpan::class.java).forEach {
setClickableText(it, this, mentions, tags, listener)
}
}
@ -284,6 +284,8 @@ fun openLinkInCustomTab(uri: Uri, context: Context) {
// https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2
// https://gts.foo.bar/@goblin
// https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5
// https://bookwyrm.foo.bar/user/User
// https://bookwyrm.foo.bar/user/User/comment/123456
fun looksLikeMastodonUrl(urlString: String): Boolean {
val uri: URI
try {
@ -304,6 +306,8 @@ fun looksLikeMastodonUrl(urlString: String): Boolean {
it.matches("^/@[^/]+/\\d+$".toRegex()) ||
it.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) ||
it.matches("^/users/\\w+$".toRegex()) ||
it.matches("^/user/[^/]+/comment/\\d+$".toRegex()) ||
it.matches("^/user/\\w+$".toRegex()) ||
it.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) ||
it.matches("^/objects/[-a-f0-9]+$".toRegex()) ||
it.matches("^/notes/[a-z0-9]+$".toRegex()) ||

View File

@ -19,7 +19,7 @@ import android.text.TextPaint
import android.text.style.URLSpan
import android.view.View
open class NoUnderlineURLSpan constructor(val url: String) : URLSpan(url) {
open class NoUnderlineURLSpan(val url: String) : URLSpan(url) {
// This should not be necessary. But if you don't do this the [StatusLengthTest] tests
// fail. Without this, accessing the `url` property, or calling `getUrl()` (which should

View File

@ -29,7 +29,6 @@ import androidx.core.graphics.drawable.IconCompat
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountEntity
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
@ -72,7 +71,7 @@ fun updateShortcut(context: Context, account: AccountEntity) {
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(NotificationHelper.ACCOUNT_ID, account.id)
putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, account.id.toString())
}
val shortcutInfo = ShortcutInfoCompat.Builder(context, account.id.toString())

View File

@ -128,7 +128,7 @@ private fun findPattern(string: String, fromIndex: Int): FindCharsResult {
val result = FindCharsResult()
for (i in fromIndex..string.lastIndex) {
val c = string[i]
for (matchType in FoundMatchType.values()) {
for (matchType in FoundMatchType.entries) {
val finder = finders[matchType]
if (finder!!.searchCharacter == c &&
(

View File

@ -30,12 +30,12 @@ import com.google.android.material.color.MaterialColors
* the ability to do so is not supported in resource files.
*/
private const val THEME_NIGHT = "night"
private const val THEME_DAY = "day"
private const val THEME_BLACK = "black"
private const val THEME_AUTO = "auto"
private const val THEME_SYSTEM = "auto_system"
const val APP_THEME_DEFAULT = THEME_NIGHT
const val THEME_NIGHT = "night"
const val THEME_DAY = "day"
const val THEME_BLACK = "black"
const val THEME_AUTO = "auto"
const val THEME_SYSTEM = "auto_system"
const val APP_THEME_DEFAULT = THEME_SYSTEM
fun getDimension(context: Context, @AttrRes attribute: Int): Int {
return context.obtainStyledAttributes(intArrayOf(attribute)).use { array ->

View File

@ -30,9 +30,9 @@ fun Throwable.getServerErrorMessage(): String? {
/** @return A drawable resource to accompany the error message for this throwable */
fun Throwable.getDrawableRes(): Int = when (this) {
is IOException -> R.drawable.elephant_offline
is HttpException -> R.drawable.elephant_offline
else -> R.drawable.elephant_error
is IOException -> R.drawable.errorphant_offline
is HttpException -> R.drawable.errorphant_offline
else -> R.drawable.errorphant_error
}
/** @return A string error message for this throwable */

View File

@ -33,7 +33,7 @@ class BackgroundMessageView @JvmOverloads constructor(
orientation = VERTICAL
if (isInEditMode) {
setup(R.drawable.elephant_offline, R.string.error_network) {}
setup(R.drawable.errorphant_offline, R.string.error_network) {}
}
}
@ -61,6 +61,7 @@ class BackgroundMessageView @JvmOverloads constructor(
binding.imageView.setImageResource(imageRes)
binding.button.setOnClickListener(clickListener)
binding.button.visible(clickListener != null)
binding.helpText.visible(false)
}
fun showHelp(@StringRes helpRes: Int) {

View File

@ -1,41 +0,0 @@
package com.keylesspalace.tusky.view
import android.content.Context
import android.util.AttributeSet
import android.widget.VideoView
class ExposedPlayPauseVideoView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) :
VideoView(context, attrs, defStyleAttr) {
private var listener: PlayPauseListener? = null
private var playing = false
fun setPlayPauseListener(listener: PlayPauseListener) {
this.listener = listener
}
override fun start() {
super.start()
if (!playing) {
playing = true
listener?.onPlay()
}
}
override fun pause() {
super.pause()
if (playing) {
playing = false
listener?.onPause()
}
}
interface PlayPauseListener {
fun onPlay()
fun onPause()
}
}

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.TypedArray
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View.VISIBLE
import androidx.appcompat.content.res.AppCompatResources
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
@ -12,6 +11,8 @@ import com.google.android.material.slider.LabelFormatter.LABEL_GONE
import com.google.android.material.slider.Slider
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.PrefSliderBinding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import java.lang.Float.max
import java.lang.Float.min
@ -130,6 +131,8 @@ class SliderPreference @JvmOverloads constructor(
binding.root.isClickable = false
binding.slider.clearOnChangeListeners()
binding.slider.clearOnSliderTouchListeners()
binding.slider.addOnChangeListener(this)
binding.slider.addOnSliderTouchListener(this)
binding.slider.value = value // sliderValue
@ -141,24 +144,24 @@ class SliderPreference @JvmOverloads constructor(
binding.slider.labelBehavior = LABEL_GONE
binding.slider.isEnabled = isEnabled
binding.summary.visibility = VISIBLE
binding.summary.show()
binding.summary.text = formatter(value)
decrementIcon?.let { icon ->
binding.decrement.icon = icon
binding.decrement.visibility = VISIBLE
binding.decrement.show()
binding.decrement.setOnClickListener {
value -= stepSize
}
}
} ?: binding.decrement.hide()
incrementIcon?.let { icon ->
binding.increment.icon = icon
binding.increment.visibility = VISIBLE
binding.increment.show()
binding.increment.setOnClickListener {
value += stepSize
}
}
} ?: binding.increment.hide()
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {

View File

@ -35,7 +35,7 @@ data class AttachmentViewData(
companion object {
@JvmStatic
fun list(status: Status): List<AttachmentViewData> {
fun list(status: Status, alwaysShowSensitiveMedia: Boolean = false): List<AttachmentViewData> {
val actionable = status.actionableStatus
return actionable.attachments.map { attachment ->
AttachmentViewData(
@ -43,7 +43,7 @@ data class AttachmentViewData(
statusId = actionable.id,
statusUrl = actionable.url!!,
sensitive = actionable.sensitive,
isRevealed = !actionable.sensitive
isRevealed = alwaysShowSensitiveMedia || !actionable.sensitive
)
}
}

View File

@ -35,7 +35,6 @@ import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
@ -43,7 +42,6 @@ import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
@ -52,6 +50,13 @@ import javax.inject.Inject
private const val HEADER_FILE_NAME = "header.png"
private const val AVATAR_FILE_NAME = "avatar.png"
internal data class ProfileDataInUi(
val displayName: String,
val note: String,
val locked: Boolean,
val fields: List<StringField>
)
class EditProfileViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
@ -64,11 +69,10 @@ class EditProfileViewModel @Inject constructor(
val headerData = MutableLiveData<Uri>()
val saveData = MutableLiveData<Resource<Nothing>>()
@OptIn(FlowPreview::class)
val instanceData: Flow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
private var oldProfileData: Account? = null
private var apiProfileAccount: Account? = null
fun obtainProfile() = viewModelScope.launch {
if (profileData.value == null || profileData.value is Error) {
@ -76,7 +80,7 @@ class EditProfileViewModel @Inject constructor(
mastodonApi.accountVerifyCredentials().fold(
{ profile ->
oldProfileData = profile
apiProfileAccount = profile
profileData.postValue(Success(profile))
},
{
@ -98,68 +102,49 @@ class EditProfileViewModel @Inject constructor(
headerData.value = getHeaderUri()
}
fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>) {
internal fun save(newProfileData: ProfileDataInUi) {
if (saveData.value is Loading || profileData.value !is Success) {
return
}
saveData.value = Loading()
val displayName = if (oldProfileData?.displayName == newDisplayName) {
null
} else {
newDisplayName.toRequestBody(MultipartBody.FORM)
}
val note = if (oldProfileData?.source?.note == newNote) {
null
} else {
newNote.toRequestBody(MultipartBody.FORM)
}
val locked = if (oldProfileData?.locked == newLocked) {
null
} else {
newLocked.toString().toRequestBody(MultipartBody.FORM)
}
val avatar = if (avatarData.value != null) {
val avatarBody = getCacheFileForName(AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull())
MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), avatarBody)
} else {
null
}
val header = if (headerData.value != null) {
val headerBody = getCacheFileForName(HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull())
MultipartBody.Part.createFormData("header", randomAlphanumericString(12), headerBody)
} else {
null
}
// when one field changed, all have to be sent or they unchanged ones would get overridden
val fieldsUnchanged = oldProfileData?.source?.fields == newFields
val field1 = calculateFieldToUpdate(newFields.getOrNull(0), fieldsUnchanged)
val field2 = calculateFieldToUpdate(newFields.getOrNull(1), fieldsUnchanged)
val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged)
val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged)
if (displayName == null && note == null && locked == null && avatar == null && header == null &&
field1 == null && field2 == null && field3 == null && field4 == null
) {
/** if nothing has changed, there is no need to make a network request */
saveData.postValue(Success())
val diff = getProfileDiff(apiProfileAccount, newProfileData)
if (!diff.hasChanges()) {
// if nothing has changed, there is no need to make an api call
saveData.value = Success()
return
}
viewModelScope.launch {
var avatarFileBody: MultipartBody.Part? = null
diff.avatarFile?.let {
avatarFileBody = MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull()))
}
var headerFileBody: MultipartBody.Part? = null
diff.headerFile?.let {
headerFileBody = MultipartBody.Part.createFormData("header", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull()))
}
mastodonApi.accountUpdateCredentials(
displayName, note, locked, avatar, header,
field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second
diff.displayName?.toRequestBody(MultipartBody.FORM),
diff.note?.toRequestBody(MultipartBody.FORM),
diff.locked?.toString()?.toRequestBody(MultipartBody.FORM),
avatarFileBody,
headerFileBody,
diff.field1?.first?.toRequestBody(MultipartBody.FORM),
diff.field1?.second?.toRequestBody(MultipartBody.FORM),
diff.field2?.first?.toRequestBody(MultipartBody.FORM),
diff.field2?.second?.toRequestBody(MultipartBody.FORM),
diff.field3?.first?.toRequestBody(MultipartBody.FORM),
diff.field3?.second?.toRequestBody(MultipartBody.FORM),
diff.field4?.first?.toRequestBody(MultipartBody.FORM),
diff.field4?.second?.toRequestBody(MultipartBody.FORM)
).fold(
{ newProfileData ->
{ newAccountData ->
saveData.postValue(Success())
eventHub.dispatch(ProfileEditedEvent(newProfileData))
eventHub.dispatch(ProfileEditedEvent(newAccountData))
},
{ throwable ->
saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage()))
@ -169,30 +154,95 @@ class EditProfileViewModel @Inject constructor(
}
// cache activity state for rotation change
fun updateProfile(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>) {
internal fun updateProfile(newProfileData: ProfileDataInUi) {
if (profileData.value is Success) {
val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields)
val newProfileSource = profileData.value?.data?.source?.copy(note = newProfileData.note, fields = newProfileData.fields)
val newProfile = profileData.value?.data?.copy(
displayName = newDisplayName,
locked = newLocked,
displayName = newProfileData.displayName,
locked = newProfileData.locked,
source = newProfileSource
)
profileData.postValue(Success(newProfile))
profileData.value = Success(newProfile)
}
}
private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair<RequestBody, RequestBody>? {
internal fun hasUnsavedChanges(newProfileData: ProfileDataInUi): Boolean {
val diff = getProfileDiff(apiProfileAccount, newProfileData)
return diff.hasChanges()
}
private fun getProfileDiff(oldProfileAccount: Account?, newProfileData: ProfileDataInUi): DiffProfileData {
val displayName = if (oldProfileAccount?.displayName == newProfileData.displayName) {
null
} else {
newProfileData.displayName
}
val note = if (oldProfileAccount?.source?.note == newProfileData.note) {
null
} else {
newProfileData.note
}
val locked = if (oldProfileAccount?.locked == newProfileData.locked) {
null
} else {
newProfileData.locked
}
val avatarFile = if (avatarData.value != null) {
getCacheFileForName(AVATAR_FILE_NAME)
} else {
null
}
val headerFile = if (headerData.value != null) {
getCacheFileForName(HEADER_FILE_NAME)
} else {
null
}
// when one field changed, all have to be sent or they unchanged ones would get overridden
val allFieldsUnchanged = oldProfileAccount?.source?.fields == newProfileData.fields
val field1 = calculateFieldToUpdate(newProfileData.fields.getOrNull(0), allFieldsUnchanged)
val field2 = calculateFieldToUpdate(newProfileData.fields.getOrNull(1), allFieldsUnchanged)
val field3 = calculateFieldToUpdate(newProfileData.fields.getOrNull(2), allFieldsUnchanged)
val field4 = calculateFieldToUpdate(newProfileData.fields.getOrNull(3), allFieldsUnchanged)
return DiffProfileData(
displayName, note, locked, field1, field2, field3, field4, headerFile, avatarFile
)
}
private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair<String, String>? {
if (fieldsUnchanged || newField == null) {
return null
}
return Pair(
newField.name.toRequestBody(MultipartBody.FORM),
newField.value.toRequestBody(MultipartBody.FORM)
newField.name,
newField.value
)
}
private fun getCacheFileForName(filename: String): File {
return File(application.cacheDir, filename)
}
private data class DiffProfileData(
val displayName: String?,
val note: String?,
val locked: Boolean?,
val field1: Pair<String, String>?,
val field2: Pair<String, String>?,
val field3: Pair<String, String>?,
val field4: Pair<String, String>?,
val headerFile: File?,
val avatarFile: File?
) {
fun hasChanges() = displayName != null || note != null || locked != null ||
avatarFile != null || headerFile != null || field1 != null || field2 != null ||
field3 != null || field4 != null
}
}

View File

@ -38,7 +38,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
}
enum class Event {
CREATE_ERROR, DELETE_ERROR, RENAME_ERROR
CREATE_ERROR, DELETE_ERROR, UPDATE_ERROR
}
data class State(val lists: List<MastoList>, val loadingState: LoadingState)
@ -84,9 +84,9 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
}
}
fun createNewList(listName: String) {
fun createNewList(listName: String, exclusive: Boolean) {
viewModelScope.launch {
api.createList(listName).fold(
api.createList(listName, exclusive).fold(
{ list ->
updateState {
copy(lists = lists + list)
@ -99,16 +99,16 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
}
}
fun renameList(listId: String, listName: String) {
fun updateList(listId: String, listName: String, exclusive: Boolean) {
viewModelScope.launch {
api.updateList(listId, listName).fold(
api.updateList(listId, listName, exclusive).fold(
{ list ->
updateState {
copy(lists = lists.replacedFirstWhich(list) { it.id == listId })
}
},
{
sendEvent(Event.RENAME_ERROR)
sendEvent(Event.UPDATE_ERROR)
}
)
}

Some files were not shown because too many files have changed in this diff Show More