Merge branch 'develop' into refactor_instancemute
This commit is contained in:
commit
a96460cb16
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
13
app/lint.xml
13
app/lint.xml
|
@ -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
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
|
@ -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) {
|
||||
|
|
|
@ -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?
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -45,6 +45,7 @@ class SearchViewModel @Inject constructor(
|
|||
) : ViewModel() {
|
||||
|
||||
var currentQuery: String = ""
|
||||
var currentSearchFieldContent: String? = null
|
||||
|
||||
val activeAccount: AccountEntity?
|
||||
get() = accountManager.activeAccount
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:')");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -22,5 +22,6 @@ package com.keylesspalace.tusky.entity
|
|||
|
||||
data class MastoList(
|
||||
val id: String,
|
||||
val title: String
|
||||
val title: String,
|
||||
val exclusive: Boolean?
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()) ||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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 &&
|
||||
(
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue