Merge remote-tracking branch 'tuskyapp/develop'

# Conflicts:
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/entity/Status.kt
#	app/src/main/res/values-be/strings.xml
#	app/src/main/res/values-cy/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-gd/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-lv/strings.xml
#	app/src/main/res/values-oc/strings.xml
#	app/src/main/res/values-sv/strings.xml
#	app/src/main/res/values-tr/strings.xml
#	app/src/main/res/values-vi/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values/donottranslate.xml
#	fastlane/metadata/android/en-US/images/phoneScreenshots/01_timeline.png
#	fastlane/metadata/android/en-US/images/phoneScreenshots/03_profile.png
#	fastlane/metadata/android/en-US/images/phoneScreenshots/04_favourites.png
#	fastlane/metadata/android/fa/full_description.txt
#	fastlane/metadata/android/sv/changelogs/58.txt
#	fastlane/metadata/android/sv/changelogs/67.txt
#	fastlane/metadata/android/sv/changelogs/72.txt
#	fastlane/metadata/android/sv/changelogs/77.txt
#	gradle/libs.versions.toml
This commit is contained in:
kyori19 2023-07-19 20:56:40 +09:00
commit e5d9b56dd0
No known key found for this signature in database
GPG Key ID: F7BDE7DD42BF366A
207 changed files with 3562 additions and 1811 deletions

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

@ -0,0 +1,41 @@
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: '11'
distribution: 'temurin'
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1
- 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

View File

@ -6,6 +6,53 @@
### Significant bug fixes ### 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
- **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)
## v22.0 ## v22.0
### New features and other improvements ### New features and other improvements

View File

@ -1,5 +1,6 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.google.ksp)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt) alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.parcelize)
@ -121,11 +122,9 @@ android {
} }
} }
kapt { ksp {
arguments { arg("room.schemaLocation", "$projectDir/schemas")
arg("room.schemaLocation", "$projectDir/schemas") arg("room.incremental", "true")
arg("room.incremental", "true")
}
} }
configurations { configurations {
@ -142,7 +141,7 @@ dependencies {
implementation libs.bundles.androidx implementation libs.bundles.androidx
implementation libs.bundles.room implementation libs.bundles.room
kapt libs.androidx.room.compiler ksp libs.androidx.room.compiler
implementation libs.android.material implementation libs.android.material
@ -156,7 +155,7 @@ dependencies {
implementation libs.conscrypt.android implementation libs.conscrypt.android
implementation libs.bundles.glide implementation libs.bundles.glide
kapt libs.glide.compiler ksp libs.glide.compiler
implementation libs.bundles.rxjava3 implementation libs.bundles.rxjava3

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,13 @@
<!-- Ensure we are warned about errors in the baseline --> <!-- Ensure we are warned about errors in the baseline -->
<issue id="LintBaseline" severity="warning" /> <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 --> <!-- Mark all other lint issues as errors -->
<issue id="all" severity="error" /> <issue id="all" severity="error" />
</lint> </lint>

View File

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State import com.keylesspalace.tusky.viewmodel.State
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
private typealias AccountInfo = Pair<TimelineAccount, Boolean> private typealias AccountInfo = Pair<TimelineAccount, Boolean>
@ -146,23 +145,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private fun handleError(error: Throwable) { private fun handleError(error: Throwable) {
binding.messageView.show() binding.messageView.show()
val retryAction = { _: View -> binding.messageView.setup(error) { _: View ->
binding.messageView.hide() binding.messageView.hide()
viewModel.load(listId) viewModel.load(listId)
} }
if (error is IOException) {
binding.messageView.setup(
R.drawable.elephant_offline,
R.string.error_network,
retryAction
)
} else {
binding.messageView.setup(
R.drawable.elephant_error,
R.string.error_generic,
retryAction
)
}
} }
private fun onRemoveFromList(accountId: String) { private fun onRemoveFromList(accountId: String) {

View File

@ -16,9 +16,11 @@
package com.keylesspalace.tusky; package com.keylesspalace.tusky;
import android.app.ActivityManager; import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Color; import android.graphics.Color;
@ -45,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.interfaces.AccountSelectionListener; import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
import com.keylesspalace.tusky.interfaces.PermissionRequester; import com.keylesspalace.tusky.interfaces.PermissionRequester;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
import java.util.ArrayList; import java.util.ArrayList;
@ -54,6 +57,7 @@ import java.util.List;
import javax.inject.Inject; import javax.inject.Inject;
public abstract class BaseActivity extends AppCompatActivity implements Injectable { public abstract class BaseActivity extends AppCompatActivity implements Injectable {
private static final String TAG = "BaseActivity";
@Inject @Inject
public AccountManager accountManager; public AccountManager accountManager;
@ -93,6 +97,44 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
requesters = new HashMap<>(); requesters = new HashMap<>();
} }
@Override
protected void attachBaseContext(Context newBase) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
Configuration configuration = newBase.getResources().getConfiguration();
// Adjust `fontScale` in the configuration.
//
// You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the
// result of previous adjustments. E.g., going from 100% to 80% to 100% does not return
// you to the original 100%, it leaves it at 80%.
//
// Instead, calculate the new scale from the application context. This is unaffected by
// changes to the base context. It does contain contain any changes to the font scale from
// "Settings > Display > Font size" in the device settings, so scaling performed here
// is in addition to any scaling in the device settings.
Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration();
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
// You can try to adjust `densityDpi` as shown in the commented out code below. This
// works, to a point. However, dialogs do not react well to this. Beyond a certain
// scale (~ 120%) the right hand edge of the dialog will clip off the right of the
// screen.
//
// So for now, just adjust the font scale
//
// val displayMetrics = appContext.resources.displayMetrics
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
Context fontScaleContext = newBase.createConfigurationContext(configuration);
super.attachBaseContext(fontScaleContext);
}
protected boolean requiresLogin() { protected boolean requiresLogin() {
return true; return true;
} }
@ -212,15 +254,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
} }
} }
// TODO: This changes the accountManager's activeAccount property, but does not do any
// of the work that AccountManager.setActiveAccount() does. In particular:
//
// - The current active account is not saved
// - The account passed as parameter here goes not have its `isActive` property set
//
// Is that deliberate? Or is this a bug?
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account); accountManager.setActiveAccount(account.getId());
Intent intent = new Intent(this, MainActivity.class); Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.REDIRECT_URL, url); intent.putExtra(MainActivity.REDIRECT_URL, url);

View File

@ -21,6 +21,8 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
@ -42,12 +44,12 @@ import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener import com.keylesspalace.tusky.adapter.ItemInteractionListener
import com.keylesspalace.tusky.adapter.ListSelectionAdapter
import com.keylesspalace.tusky.adapter.TabAdapter import com.keylesspalace.tusky.adapter.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
@ -280,7 +282,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
} }
private fun showSelectListDialog() { private fun showSelectListDialog() {
val adapter = ListSelectionAdapter(this) 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) val statusLayout = LinearLayout(this)
statusLayout.gravity = Gravity.CENTER statusLayout.gravity = Gravity.CENTER
@ -306,12 +314,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setView(statusLayout) .setView(statusLayout)
.setAdapter(adapter) { _, position -> .setAdapter(adapter) { _, position ->
val list = adapter.getItem(position) adapter.getItem(position)?.let { item ->
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title)) val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
currentTabs.add(newTab) currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs() updateAvailableTabs()
saveTabs() saveTabs()
}
} }
val showProgressBarJob = getProgressBarJob(progress, 500) val showProgressBarJob = getProgressBarJob(progress, 500)

View File

@ -18,15 +18,20 @@ package com.keylesspalace.tusky
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.SCHEMA_VERSION import com.keylesspalace.tusky.settings.SCHEMA_VERSION
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.util.setAppNightMode
import com.keylesspalace.tusky.worker.PruneCacheWorker
import com.keylesspalace.tusky.worker.WorkerFactory
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
@ -35,6 +40,7 @@ import de.c1710.filemojicompat_ui.helpers.EmojiPreference
import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt import org.conscrypt.Conscrypt
import java.security.Security import java.security.Security
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
class TuskyApplication : Application(), HasAndroidInjector { class TuskyApplication : Application(), HasAndroidInjector {
@ -42,7 +48,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
lateinit var androidInjector: DispatchingAndroidInjector<Any> lateinit var androidInjector: DispatchingAndroidInjector<Any>
@Inject @Inject
lateinit var notificationWorkerFactory: NotificationWorkerFactory lateinit var workerFactory: WorkerFactory
@Inject @Inject
lateinit var localeManager: LocaleManager lateinit var localeManager: LocaleManager
@ -90,12 +96,24 @@ class TuskyApplication : Application(), HasAndroidInjector {
Log.w("RxJava", "undeliverable exception", it) Log.w("RxJava", "undeliverable exception", it)
} }
NotificationHelper.createWorkerNotificationChannel(this)
WorkManager.initialize( WorkManager.initialize(
this, this,
androidx.work.Configuration.Builder() androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory) .setWorkerFactory(workerFactory)
.build() .build()
) )
// Prune the database every ~ 12 hours when the device is idle.
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
PruneCacheWorker.PERIODIC_WORK_TAG,
ExistingPeriodicWorkPolicy.KEEP,
pruneCacheWorker
)
} }
override fun androidInjector() = androidInjector override fun androidInjector() = androidInjector

View File

@ -39,6 +39,7 @@ import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
@ -96,7 +97,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
supportPostponeEnterTransition() supportPostponeEnterTransition()
// Gather the parameters. // Gather the parameters.
attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS) attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener // Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener

View File

@ -22,6 +22,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.entity.StringField
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.fixTextSelection
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() { class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
@ -81,12 +82,16 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
} }
holder.binding.accountFieldNameText.doAfterTextChanged { newText -> holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].first = newText.toString() fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
} }
holder.binding.accountFieldValueText.doAfterTextChanged { newText -> holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
fieldData[holder.bindingAdapterPosition].second = newText.toString() fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
} }
// Ensure the textview contents are selectable
holder.binding.accountFieldNameText.fixTextSelection()
holder.binding.accountFieldValueText.fixTextSelection()
} }
class MutableStringPair(var first: String, var second: String) class MutableStringPair(var first: String, var second: String)

View File

@ -1,41 +0,0 @@
/* Copyright 2019 kyori19
*
* 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.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPickerListBinding
import com.keylesspalace.tusky.entity.MastoList
class ListSelectionAdapter(context: Context) : ArrayAdapter<MastoList>(context, R.layout.item_picker_list) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val binding = if (convertView == null) {
ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false)
} else {
ItemPickerListBinding.bind(convertView)
}
getItem(position)?.let { list ->
binding.root.text = list.title
}
return binding.root
}
}

View File

@ -476,7 +476,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (replyCountLabel == null) return; if (replyCountLabel == null) return;
if (fullStats) { if (fullStats) {
replyCountLabel.setText(NumberUtils.shortNumber(repliesCount)); replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000));
return; return;
} }

View File

@ -114,11 +114,11 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
protected void setReblogsCount(int reblogsCount) { protected void setReblogsCount(int reblogsCount) {
reblogsCountLabel.setText(NumberUtils.shortNumber(reblogsCount)); reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000));
} }
protected void setFavouritedCount(int favouritedCount) { protected void setFavouritedCount(int favouritedCount) {
favouritedCountLabel.setText(NumberUtils.shortNumber(favouritedCount)); favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000));
} }
protected void hideStatusInfo() { protected void hideStatusInfo() {

View File

@ -1,94 +0,0 @@
/* 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.adapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.entity.TrendingTagHistory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.viewdata.TrendingViewData
import java.text.NumberFormat
import kotlin.math.ln
import kotlin.math.pow
class TrendingTagViewHolder(
private val binding: ItemTrendingCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun setup(
tagViewData: TrendingViewData.Tag,
maxTrendingValue: Long,
trendingListener: LinkListener
) {
val reversedHistory = tagViewData.tag.history.reversed()
setGraph(reversedHistory, maxTrendingValue)
setTag(tagViewData.tag.name)
val totalUsage = tagViewData.tag.history.sumOf { it.uses.toLongOrNull() ?: 0 }
binding.totalUsage.text = formatNumber(totalUsage)
val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toLongOrNull() ?: 0 }
binding.totalAccounts.text = formatNumber(totalAccounts)
binding.currentUsage.text = reversedHistory.last().uses
binding.currentAccounts.text = reversedHistory.last().accounts
itemView.setOnClickListener {
trendingListener.onViewTag(tagViewData.tag.name)
}
setAccessibility(totalAccounts, tagViewData.tag.name)
}
private fun setGraph(history: List<TrendingTagHistory>, maxTrendingValue: Long) {
binding.graph.maxTrendingValue = maxTrendingValue
binding.graph.primaryLineData = history
.mapNotNull { it.uses.toLongOrNull() }
binding.graph.secondaryLineData = history
.mapNotNull { it.accounts.toLongOrNull() }
}
private fun setTag(tag: String) {
binding.tag.text = binding.root.context.getString(R.string.title_tag, tag)
}
private fun setAccessibility(totalAccounts: Long, tag: String) {
itemView.contentDescription =
itemView.context.getString(R.string.accessibility_talking_about_tag, totalAccounts, tag)
}
companion object {
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
private val ln_1k = ln(1000.0)
/**
* Format numbers according to the current locale. Numbers < min have
* separators (',', '.', etc) inserted according to the locale.
*
* Numbers > min are scaled down to that by multiples of 1,000, and
* a suffix appropriate to the scaling is appended.
*/
private fun formatNumber(num: Long, min: Int = 100000): String {
if (num < min) return numberFormatter.format(num)
val exp = (ln(num.toDouble()) / ln_1k).toInt()
// TODO: is the choice of suffixes here locale-agnostic?
return String.format("%.1f %c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
}
}
}

View File

@ -40,7 +40,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class ListsForAccountFragment : DialogFragment(), Injectable { class ListsForAccountFragment : DialogFragment(), Injectable {
@ -103,16 +102,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
binding.listsView.hide() binding.listsView.hide()
binding.messageView.apply { binding.messageView.apply {
show() show()
setup(error) { load() }
if (error is IOException) {
setup(R.drawable.elephant_offline, R.string.error_network) {
load()
}
} else {
setup(R.drawable.elephant_error, R.string.error_generic) {
load()
}
}
} }
} }
} }

View File

@ -51,7 +51,6 @@ import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -133,12 +132,7 @@ class AccountMediaFragment :
} }
is LoadState.Error -> { is LoadState.Error -> {
binding.statusView.show() binding.statusView.show()
binding.statusView.setup((loadState.refresh as LoadState.Error).error)
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
}
} }
is LoadState.Loading -> { is LoadState.Loading -> {
binding.progressBar.show() binding.progressBar.show()

View File

@ -393,16 +393,9 @@ class AccountListFragment :
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
binding.messageView.show() binding.messageView.show()
if (throwable is IOException) { binding.messageView.setup(throwable) {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { binding.messageView.hide()
binding.messageView.hide() this.fetchAccounts(null)
this.fetchAccounts(null)
}
} else {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.hide()
this.fetchAccounts(null)
}
} }
} }
} }

View File

@ -54,7 +54,9 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.core.content.res.use import androidx.core.content.res.use
import androidx.core.os.BundleCompat
import androidx.core.view.ContentInfoCompat import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
@ -79,6 +81,7 @@ import com.keylesspalace.tusky.adapter.LocaleAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
@ -256,7 +259,7 @@ class ComposeActivity :
/* If the composer is started up as a reply to another post, override the "starting" state /* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */ * based on what the intent from the reply request passes. */
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java)
viewModel.setup(composeOptions, useCachedData(preferences, composeOptions?.tootRightNow == true)) viewModel.setup(composeOptions, useCachedData(preferences, composeOptions?.tootRightNow == true))
setupButtons() setupButtons()
@ -292,7 +295,7 @@ class ComposeActivity :
/* Finally, overwrite state with data from saved instance state. */ /* Finally, overwrite state with data from saved instance state. */
savedInstanceState?.let { savedInstanceState?.let {
photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY) photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java)
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply { (it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
setStatusVisibility(this) setStatusVisibility(this)
@ -338,12 +341,12 @@ class ComposeActivity :
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
when (intent.action) { when (intent.action) {
Intent.ACTION_SEND -> { Intent.ACTION_SEND -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri -> IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
pickMedia(uri) pickMedia(uri)
} }
} }
Intent.ACTION_SEND_MULTIPLE -> { Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri -> IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
pickMedia(uri) pickMedia(uri)
} }
} }
@ -1017,7 +1020,10 @@ class ComposeActivity :
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null } val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content -> split.first?.let { content ->
for (i in 0 until content.clip.itemCount) { 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 return split.second
@ -1142,9 +1148,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item) viewModel.removeMediaFromQueue(item)
} }
private fun pickMedia(uri: Uri) { private fun pickMedia(uri: Uri, description: String? = null) {
lifecycleScope.launch { lifecycleScope.launch {
viewModel.pickMedia(uri).onFailure { throwable -> viewModel.pickMedia(uri, description).onFailure { throwable ->
val errorString = when (throwable) { val errorString = when (throwable) {
is FileSizeException -> { is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##") val decimalFormat = DecimalFormat("0.##")
@ -1205,16 +1211,19 @@ class ComposeActivity :
private fun handleCloseButton() { private fun handleCloseButton() {
val contentText = binding.composeEditField.text.toString() val contentText = binding.composeEditField.text.toString()
val contentWarning = binding.composeContentWarningField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString()
if (viewModel.didChange(contentText, contentWarning)) { when (viewModel.handleCloseButton(contentText, contentWarning)) {
when (viewModel.composeKind) { ConfirmationKind.NONE -> {
ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning) viewModel.stopUploads()
ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning) finishWithoutSlideOutAnimation()
ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog() }
ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog() ConfirmationKind.SAVE_OR_DISCARD ->
}.show() getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
} else { ConfirmationKind.UPDATE_OR_DISCARD ->
viewModel.stopUploads() getUpdateDraftOrDiscardDialog(contentText, contentWarning).show()
finishWithoutSlideOutAnimation() ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES ->
getContinueEditingOrDiscardDialog().show()
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT ->
getDeleteEmptyDraftOrContinueEditing().show()
} }
} }
@ -1279,6 +1288,23 @@ class ComposeActivity :
} }
} }
/**
* User is editing an existing draft and making it empty.
* The user can either delete the empty draft or go back to editing.
*/
private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder {
return AlertDialog.Builder(this)
.setMessage(R.string.compose_delete_draft)
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteDraft()
viewModel.stopUploads()
finishWithoutSlideOutAnimation()
}
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing
}
}
private fun deleteDraftAndFinish() { private fun deleteDraftAndFinish() {
viewModel.deleteDraft() viewModel.deleteDraft()
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()

View File

@ -21,6 +21,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
@ -105,7 +106,7 @@ class ComposeViewModel @Inject constructor(
val domain = accountManager.activeAccount?.domain!! val domain = accountManager.activeAccount?.domain!!
lateinit var composeKind: ComposeActivity.ComposeKind lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight // Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null var cropImageItemOld: QueuedMedia? = null
@ -224,7 +225,28 @@ class ComposeViewModel @Inject constructor(
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
} }
fun didChange(content: String?, contentWarning: String?): Boolean { fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
return if (didChange(contentText, contentWarning)) {
when (composeKind) {
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
ConfirmationKind.NONE
} else {
ConfirmationKind.SAVE_OR_DISCARD
}
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
} else {
ConfirmationKind.UPDATE_OR_DISCARD
}
ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
}
} else {
ConfirmationKind.NONE
}
}
private fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = content.orEmpty() != startingText.orEmpty() val textChanged = content.orEmpty() != startingText.orEmpty()
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
val mediaChanged = media.value.isNotEmpty() val mediaChanged = media.value.isNotEmpty()
@ -234,6 +256,10 @@ class ComposeViewModel @Inject constructor(
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
} }
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
}
fun contentWarningChanged(value: Boolean) { fun contentWarningChanged(value: Boolean) {
showContentWarning.value = value showContentWarning.value = value
contentWarningStateChanged = true contentWarningStateChanged = true
@ -402,7 +428,7 @@ class ComposeViewModel @Inject constructor(
return return
} }
composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW composeKind = composeOptions?.kind ?: ComposeKind.NEW
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
@ -507,6 +533,14 @@ class ComposeViewModel @Inject constructor(
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }
enum class ConfirmationKind {
NONE, // just close
SAVE_OR_DISCARD,
UPDATE_OR_DISCARD,
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
}
} }
/** /**

View File

@ -27,6 +27,7 @@ import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.widget.EditText import android.widget.EditText
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -73,7 +74,7 @@ class CaptionDialog : DialogFragment() {
val window = dialog.window val window = dialog.window
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
val previewUri = arguments?.getParcelable<Uri>(PREVIEW_URI_ARG) ?: error("Preview Uri is null") val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
// Load the image and manually set it into the ImageView because it doesn't have a fixed size. // Load the image and manually set it into the ImageView because it doesn't have a fixed size.
Glide.with(this) Glide.with(this)
.load(previewUri) .load(previewUri)

View File

@ -15,13 +15,13 @@
package com.keylesspalace.tusky.components.compose.dialog package com.keylesspalace.tusky.components.compose.dialog
import android.app.Activity
import android.content.DialogInterface import android.content.DialogInterface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -38,7 +38,7 @@ fun <T> T.makeFocusDialog(
existingFocus: Focus?, existingFocus: Focus?,
previewUri: Uri, previewUri: Uri,
onUpdateFocus: suspend (Focus) -> Unit onUpdateFocus: suspend (Focus) -> Unit
) where T : Activity, T : LifecycleOwner { ) where T : AppCompatActivity, T : LifecycleOwner {
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
val dialogBinding = DialogFocusBinding.inflate(layoutInflater) val dialogBinding = DialogFocusBinding.inflate(layoutInflater)

View File

@ -89,7 +89,7 @@ data class ConversationStatusEntity(
val bookmarked: Boolean, val bookmarked: Boolean,
val sensitive: Boolean, val sensitive: Boolean,
val spoilerText: String, val spoilerText: String,
val attachments: ArrayList<Attachment>, val attachments: List<Attachment>,
val mentions: List<Status.Mention>, val mentions: List<Status.Mention>,
val tags: List<HashTag>?, val tags: List<HashTag>?,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,

View File

@ -65,7 +65,6 @@ import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.toDuration import kotlin.time.toDuration
@ -141,16 +140,7 @@ class ConversationsFragment :
} }
is LoadState.Error -> { is LoadState.Error -> {
binding.statusView.show() binding.statusView.show()
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshContent()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshContent()
}
}
} }
is LoadState.Loading -> { is LoadState.Loading -> {
binding.progressBar.show() binding.progressBar.show()

View File

@ -7,9 +7,11 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.view.size import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
@ -23,7 +25,9 @@ import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
@ -47,7 +51,7 @@ class EditFilterActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT) originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf()) filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
binding.apply { binding.apply {
contextSwitches = mapOf( contextSwitches = mapOf(
@ -77,6 +81,9 @@ class EditFilterActivity : BaseActivity() {
binding.actionChip.setOnClickListener { showAddKeywordDialog() } binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() } binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
binding.filterDeleteButton.visible(originalFilter != null)
for (switch in contextSwitches.keys) { for (switch in contextSwitches.keys) {
switch.setOnCheckedChangeListener { _, isChecked -> switch.setOnCheckedChangeListener { _, isChecked ->
val context = contextSwitches[switch]!! val context = contextSwitches[switch]!!
@ -258,6 +265,32 @@ class EditFilterActivity : BaseActivity() {
} }
} }
private fun deleteFilter() {
originalFilter?.let { filter ->
lifecycleScope.launch {
api.deleteFilter(filter.id).fold(
{
finish()
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
api.deleteFilterV1(filter.id).fold(
{
finish()
},
{
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
)
} else {
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
}
)
}
}
}
companion object { companion object {
const val FILTER_TO_EDIT = "FilterToEdit" const val FILTER_TO_EDIT = "FilterToEdit"

View File

@ -31,7 +31,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class FollowedTagsActivity : class FollowedTagsActivity :
@ -108,11 +107,7 @@ class FollowedTagsActivity :
binding.followedTagsView.hide() binding.followedTagsView.hide()
binding.followedTagsMessageView.show() binding.followedTagsMessageView.show()
val errorState = loadState.refresh as LoadState.Error val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) { binding.followedTagsMessageView.setup(errorState.error) { retry() }
binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() }
} else {
binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() }
}
Log.w(TAG, "error loading followed hashtags", errorState.error) Log.w(TAG, "error loading followed hashtags", errorState.error)
} else { } else {
binding.followedTagsView.show() binding.followedTagsView.show()

View File

@ -26,7 +26,6 @@ import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@ -146,16 +145,9 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
binding.messageView.show() binding.messageView.show()
if (throwable is IOException) { binding.messageView.setup(throwable) {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { binding.messageView.hide()
binding.messageView.hide() this.fetchInstances(null)
this.fetchInstances(null)
}
} else {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.hide()
this.fetchInstances(null)
}
} }
} }
} }

View File

@ -33,6 +33,7 @@ import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
@ -61,7 +62,9 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
return if (resultCode == Activity.RESULT_CANCELED) { return if (resultCode == Activity.RESULT_CANCELED) {
LoginResult.Cancel LoginResult.Cancel
} else { } else {
intent!!.getParcelableExtra(RESULT_EXTRA)!! intent?.let {
IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java)
} ?: LoginResult.Err("failed parsing LoginWebViewActivity result")
} }
} }
@ -70,7 +73,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
private const val DATA_EXTRA = "data" private const val DATA_EXTRA = "data"
fun parseData(intent: Intent): LoginData { fun parseData(intent: Intent): LoginData {
return intent.getParcelableExtra(DATA_EXTRA)!! return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!!
} }
fun makeResultIntent(result: LoginResult): Intent { fun makeResultIntent(result: LoginResult): Intent {

View File

@ -11,9 +11,10 @@ import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThan
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
import kotlin.time.Duration.Companion.milliseconds
/** /**
* Fetch Mastodon notifications and show Android notifications, with summaries, for them. * Fetch Mastodon notifications and show Android notifications, with summaries, for them.
@ -29,19 +30,17 @@ class NotificationFetcher @Inject constructor(
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val context: Context private val context: Context
) { ) {
fun fetchAndShow() { suspend fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) { for (account in accountManager.getAllAccountsOrderedByActive()) {
if (account.notificationsEnabled) { if (account.notificationsEnabled) {
try { try {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create sorted list of new notifications // Create sorted list of new notifications
val notifications = runBlocking { // OK, because in a worker thread val notifications = fetchNewNotifications(account)
fetchNewNotifications(account) .filter { filterNotification(notificationManager, account, it) }
.filter { filterNotification(notificationManager, account, it) } .sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first .toMutableList()
.toMutableList()
}
// There's a maximum limit on the number of notifications an Android app // There's a maximum limit on the number of notifications an Android app
// can display. If the total number of notifications (current notifications, // can display. If the total number of notifications (current notifications,
@ -82,7 +81,7 @@ class NotificationFetcher @Inject constructor(
// Android will rate limit / drop notifications if they're posted too // Android will rate limit / drop notifications if they're posted too
// quickly. There is no indication to the user that this happened. // quickly. There is no indication to the user that this happened.
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
Thread.sleep(1000) delay(1000.milliseconds)
} }
NotificationHelper.updateSummaryNotifications( NotificationHelper.updateSummaryNotifications(

View File

@ -37,6 +37,7 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.RemoteInput; import androidx.core.app.RemoteInput;
import androidx.core.app.TaskStackBuilder; import androidx.core.app.TaskStackBuilder;
@ -63,6 +64,7 @@ import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.worker.NotificationWorker;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -76,7 +78,12 @@ import java.util.concurrent.TimeUnit;
public class NotificationHelper { public class NotificationHelper {
private static int notificationId = 0; /** ID of notification shown when fetching notifications */
public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
/** ID of notification shown when pruning the cache */
public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
/** Dynamic notification IDs start here */
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
/** /**
* constants used in Intents * constants used in Intents
@ -120,6 +127,7 @@ public class NotificationHelper {
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES"; public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
public static final String CHANNEL_REPORT = "CHANNEL_REPORT"; public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
/** /**
* WorkManager Tag * WorkManager Tag
@ -471,6 +479,49 @@ public class NotificationHelper {
pendingIntentFlags(false)); pendingIntentFlags(false));
} }
/**
* Creates a notification channel for notifications for background work that should not
* disturb the user.
*
* @param context context
*/
public static void createWorkerNotificationChannel(@NonNull Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(
CHANNEL_BACKGROUND_TASKS,
context.getString(R.string.notification_listenable_worker_name),
NotificationManager.IMPORTANCE_NONE
);
channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
channel.enableLights(false);
channel.enableVibration(false);
channel.setShowBadge(false);
notificationManager.createNotificationChannel(channel);
}
/**
* Creates a notification for a background worker.
*
* @param context context
* @param titleResource String resource to use as the notification's title
* @return the notification
*/
@NonNull
public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
String title = context.getString(titleResource);
return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
.setContentTitle(title)
.setTicker(title)
.setSmallIcon(R.drawable.ic_notify)
.setOngoing(true)
.build();
}
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -1,51 +0,0 @@
/* Copyright 2020 Tusky Contributors
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* Lesser 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 Lesser
* General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along with Tusky. If
* not, see <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import javax.inject.Inject
class NotificationWorker(
context: Context,
params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
) : Worker(context, params) {
override fun doWork(): Result {
notificationsFetcher.fetchAndShow()
return Result.success()
}
}
class NotificationWorkerFactory @Inject constructor(
private val notificationsFetcher: NotificationFetcher
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
if (workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, notificationsFetcher)
}
return null
}
}

View File

@ -41,6 +41,7 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
@ -201,7 +202,7 @@ class NotificationsFragment :
// Save the ID of the first notification visible in the list, so the user's // Save the ID of the first notification visible in the list, so the user's
// reading position is always restorable. // reading position is always restorable.
layoutManager.findFirstVisibleItemPosition().takeIf { it >= 0 }?.let { position -> layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
adapter.snapshot().getOrNull(position)?.id?.let { id -> adapter.snapshot().getOrNull(position)?.id?.let { id ->
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
} }
@ -267,7 +268,7 @@ class NotificationsFragment :
Log.d(TAG, error.toString()) Log.d(TAG, error.toString())
val message = getString( val message = getString(
error.message, error.message,
error.exception.localizedMessage error.throwable.localizedMessage
?: getString(R.string.ui_error_unknown) ?: getString(R.string.ui_error_unknown)
) )
val snackbar = Snackbar.make( val snackbar = Snackbar.make(
@ -453,6 +454,10 @@ class NotificationsFragment :
onRefresh() onRefresh()
true true
} }
R.id.load_newest -> {
viewModel.accept(InfallibleUiAction.LoadNewest)
true
}
else -> false else -> false
} }
} }

View File

@ -117,10 +117,6 @@ class NotificationsPagingAdapter(
) )
} }
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
return NotificationViewKind.from(getItem(position)?.type).ordinal return NotificationViewKind.from(getItem(position)?.type).ordinal
} }

View File

@ -204,8 +204,9 @@ class NotificationsPagingSource @Inject constructor(
override fun getRefreshKey(state: PagingState<String, Notification>): String? { override fun getRefreshKey(state: PagingState<String, Notification>): String? {
return state.anchorPosition?.let { anchorPosition -> return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition) val id = state.closestItemToPosition(anchorPosition)?.id
anchorPage?.prevKey ?: anchorPage?.nextKey Log.d(TAG, " getRefreshKey returning $id")
return id
} }
} }

View File

@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map import androidx.paging.map
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
@ -40,11 +41,13 @@ import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.util.serialize
import com.keylesspalace.tusky.util.throttleFirst
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -52,19 +55,22 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import retrofit2.HttpException import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
data class UiState( data class UiState(
/** Filtered notification types */ /** Filtered notification types */
@ -118,6 +124,12 @@ sealed class InfallibleUiAction : UiAction() {
* can do. * can do.
*/ */
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
/** Ignore the saved reading position, load the page with the newest items */
// 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()
} }
/** Actions the user can trigger on an individual notification. These may fail. */ /** Actions the user can trigger on an individual notification. These may fail. */
@ -218,7 +230,7 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
/** Errors from fallible view model actions that the UI will need to show */ /** Errors from fallible view model actions that the UI will need to show */
sealed class UiError( sealed class UiError(
/** The exception associated with the error */ /** The exception associated with the error */
open val exception: Exception, open val throwable: Throwable,
/** String resource with an error message to show the user */ /** String resource with an error message to show the user */
@StringRes val message: Int, @StringRes val message: Int,
@ -226,55 +238,55 @@ sealed class UiError(
/** The action that failed. Can be resent to retry the action */ /** The action that failed. Can be resent to retry the action */
open val action: UiAction? = null open val action: UiAction? = null
) { ) {
data class ClearNotifications(override val exception: Exception) : UiError( data class ClearNotifications(override val throwable: Throwable) : UiError(
exception, throwable,
R.string.ui_error_clear_notifications R.string.ui_error_clear_notifications
) )
data class Bookmark( data class Bookmark(
override val exception: Exception, override val throwable: Throwable,
override val action: StatusAction.Bookmark override val action: StatusAction.Bookmark
) : UiError(exception, R.string.ui_error_bookmark, action) ) : UiError(throwable, R.string.ui_error_bookmark, action)
data class Favourite( data class Favourite(
override val exception: Exception, override val throwable: Throwable,
override val action: StatusAction.Favourite override val action: StatusAction.Favourite
) : UiError(exception, R.string.ui_error_favourite, action) ) : UiError(throwable, R.string.ui_error_favourite, action)
data class Reblog( data class Reblog(
override val exception: Exception, override val throwable: Throwable,
override val action: StatusAction.Reblog override val action: StatusAction.Reblog
) : UiError(exception, R.string.ui_error_reblog, action) ) : UiError(throwable, R.string.ui_error_reblog, action)
data class VoteInPoll( data class VoteInPoll(
override val exception: Exception, override val throwable: Throwable,
override val action: StatusAction.VoteInPoll override val action: StatusAction.VoteInPoll
) : UiError(exception, R.string.ui_error_vote, action) ) : UiError(throwable, R.string.ui_error_vote, action)
data class AcceptFollowRequest( data class AcceptFollowRequest(
override val exception: Exception, override val throwable: Throwable,
override val action: NotificationAction.AcceptFollowRequest override val action: NotificationAction.AcceptFollowRequest
) : UiError(exception, R.string.ui_error_accept_follow_request, action) ) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
data class RejectFollowRequest( data class RejectFollowRequest(
override val exception: Exception, override val throwable: Throwable,
override val action: NotificationAction.RejectFollowRequest override val action: NotificationAction.RejectFollowRequest
) : UiError(exception, R.string.ui_error_reject_follow_request, action) ) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
companion object { companion object {
fun make(exception: Exception, action: FallibleUiAction) = when (action) { fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(exception, action) is StatusAction.Bookmark -> Bookmark(throwable, action)
is StatusAction.Favourite -> Favourite(exception, action) is StatusAction.Favourite -> Favourite(throwable, action)
is StatusAction.Reblog -> Reblog(exception, action) is StatusAction.Reblog -> Reblog(throwable, action)
is StatusAction.VoteInPoll -> VoteInPoll(exception, action) is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action) is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action) is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
FallibleUiAction.ClearNotifications -> ClearNotifications(exception) FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
} }
} }
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
class NotificationsViewModel @Inject constructor( class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository, private val repository: NotificationsRepository,
private val preferences: SharedPreferences, private val preferences: SharedPreferences,
@ -295,15 +307,25 @@ class NotificationsViewModel @Inject constructor(
/** Flow of user actions received from the UI */ /** Flow of user actions received from the UI */
private val uiAction = MutableSharedFlow<UiAction>() private val uiAction = MutableSharedFlow<UiAction>()
/** Flow that can be used to trigger a full reload */
private val reload = MutableStateFlow(0)
/** Flow of successful action results */ /** Flow of successful action results */
// Note: These are a SharedFlow instead of a StateFlow because success or error state does not // Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
// need to be retained. A message is shown once to a user and then dismissed. Re-collecting the // retained. A message is shown once to a user and then dismissed. Re-collecting the flow
// flow (e.g., after a device orientation change) should not re-show the most recent success or // (e.g., after a device orientation change) should not re-show the most recent success
// error message, as it will be confusing to the user. // message, as it will be confusing to the user.
val uiSuccess = MutableSharedFlow<UiSuccess>() val uiSuccess = MutableSharedFlow<UiSuccess>()
/** Flow of transient errors for the UI to present */ /** Channel for error results */
val uiError = MutableSharedFlow<UiError>() // Errors are sent to a channel to ensure that any errors that occur *before* there are any
// subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it
// was a StateFlow any errors would be retained, and there would need to be an explicit
// mechanism to dismiss them.
private val _uiErrorChannel = Channel<UiError>()
/** Expose UI errors as a flow */
val uiError = _uiErrorChannel.receiveAsFlow()
/** Accept UI actions in to actionStateFlow */ /** Accept UI actions in to actionStateFlow */
val accept: (UiAction) -> Unit = { action -> val accept: (UiAction) -> Unit = { action ->
@ -330,6 +352,18 @@ class NotificationsViewModel @Inject constructor(
) )
} }
// Reset the last notification ID to "0" to fetch the newest notifications, and
// increment `reload` to trigger creation of a new PagingSource.
viewModelScope.launch {
uiAction
.filterIsInstance<InfallibleUiAction.LoadNewest>()
.collectLatest {
account.lastNotificationId = "0"
accountManager.saveAccount(account)
reload.getAndUpdate { it + 1 }
}
}
// Save the visible notification ID // Save the visible notification ID
viewModelScope.launch { viewModelScope.launch {
uiAction uiAction
@ -378,11 +412,11 @@ class NotificationsViewModel @Inject constructor(
if (this.isSuccessful) { if (this.isSuccessful) {
repository.invalidate() repository.invalidate()
} else { } else {
uiError.emit(UiError.make(HttpException(this), it)) _uiErrorChannel.send(UiError.make(HttpException(this), it))
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, it)) } ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
} }
} }
} }
@ -390,7 +424,7 @@ class NotificationsViewModel @Inject constructor(
// Handle NotificationAction.* // Handle NotificationAction.*
viewModelScope.launch { viewModelScope.launch {
uiAction.filterIsInstance<NotificationAction>() uiAction.filterIsInstance<NotificationAction>()
.debounce(DEBOUNCE_TIMEOUT_MS) .throttleFirst(THROTTLE_TIMEOUT)
.collect { action -> .collect { action ->
try { try {
when (action) { when (action) {
@ -401,7 +435,7 @@ class NotificationsViewModel @Inject constructor(
} }
uiSuccess.emit(NotificationActionSuccess.from(action)) uiSuccess.emit(NotificationActionSuccess.from(action))
} catch (e: Exception) { } catch (e: Exception) {
ifExpected(e) { uiError.emit(UiError.make(e, action)) } ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
} }
} }
} }
@ -409,7 +443,7 @@ class NotificationsViewModel @Inject constructor(
// Handle StatusAction.* // Handle StatusAction.*
viewModelScope.launch { viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>() uiAction.filterIsInstance<StatusAction>()
.debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps .throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps
.collect { action -> .collect { action ->
try { try {
when (action) { when (action) {
@ -434,10 +468,10 @@ class NotificationsViewModel @Inject constructor(
action.poll.id, action.poll.id,
action.choices action.choices
) )
} }.getOrThrow()
uiSuccess.emit(StatusActionSuccess.from(action)) uiSuccess.emit(StatusActionSuccess.from(action))
} catch (e: Exception) { } catch (t: Throwable) {
ifExpected(e) { uiError.emit(UiError.make(e, action)) } _uiErrorChannel.send(UiError.make(t, action))
} }
} }
} }
@ -453,11 +487,12 @@ class NotificationsViewModel @Inject constructor(
} }
} }
pagingData = notificationFilter // Re-fetch notifications if either of `notificationFilter` or `reload` flows have
// new items.
pagingData = combine(notificationFilter, reload) { action, _ -> action }
.flatMapLatest { action -> .flatMapLatest { action ->
getNotifications(filters = action.filter, initialKey = getInitialKey()) getNotifications(filters = action.filter, initialKey = getInitialKey())
} }.cachedIn(viewModelScope)
.cachedIn(viewModelScope)
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
UiState( UiState(
@ -517,6 +552,6 @@ class NotificationsViewModel @Inject constructor(
companion object { companion object {
private const val TAG = "NotificationsViewModel" private const val TAG = "NotificationsViewModel"
private const val DEBOUNCE_TIMEOUT_MS = 500L private val THROTTLE_TIMEOUT = 500.milliseconds
} }
} }

View File

@ -21,7 +21,6 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -30,7 +29,6 @@ import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
@ -42,7 +40,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.AccountPreferenceHandler import com.keylesspalace.tusky.settings.AccountPreferenceDataStore
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.makePreferenceScreen
@ -58,7 +56,6 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeRes import com.mikepenz.iconics.utils.sizeRes
import kotlinx.coroutines.launch
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
@ -74,6 +71,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -245,27 +245,26 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_timelines) { preferenceCategory(R.string.pref_title_timelines) {
// TODO having no activeAccount in this fragment does not really make sense, enforce it? // TODO having no activeAccount in this fragment does not really make sense, enforce it?
// All other locations here make it optional, however. // All other locations here make it optional, however.
val accountPreferenceHandler = AccountPreferenceHandler(accountManager.activeAccount!!, accountManager, ::dispatchEvent)
switchPreference { switchPreference {
key = PrefKeys.MEDIA_PREVIEW_ENABLED key = PrefKeys.MEDIA_PREVIEW_ENABLED
setTitle(R.string.pref_title_show_media_preview) setTitle(R.string.pref_title_show_media_preview)
isSingleLineTitle = false isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler preferenceDataStore = accountPreferenceDataStore
} }
switchPreference { switchPreference {
key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA
setTitle(R.string.pref_title_alway_show_sensitive_media) setTitle(R.string.pref_title_alway_show_sensitive_media)
isSingleLineTitle = false isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler preferenceDataStore = accountPreferenceDataStore
} }
switchPreference { switchPreference {
key = PrefKeys.ALWAYS_OPEN_SPOILER key = PrefKeys.ALWAYS_OPEN_SPOILER
setTitle(R.string.pref_title_alway_open_spoiler) setTitle(R.string.pref_title_alway_open_spoiler)
isSingleLineTitle = false isSingleLineTitle = false
preferenceDataStore = accountPreferenceHandler preferenceDataStore = accountPreferenceDataStore
} }
} }
} }
@ -353,12 +352,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
} }
private fun dispatchEvent(event: PreferenceChangedEvent) {
lifecycleScope.launch {
eventHub.dispatch(event)
}
}
companion object { companion object {
fun newInstance() = AccountPreferencesFragment() fun newInstance() = AccountPreferencesFragment()
} }

View File

@ -95,7 +95,9 @@ class PreferencesActivity :
} }
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false restartActivitiesOnBackPressedCallback.isEnabled = intent.extras?.getBoolean(
EXTRA_RESTART_ON_BACK
) ?: savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
} }
override fun onPreferenceStartFragment( override fun onPreferenceStartFragment(
@ -151,6 +153,10 @@ class PreferencesActivity :
restartActivitiesOnBackPressedCallback.isEnabled = true restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity() this.restartCurrentActivity()
} }
PrefKeys.UI_TEXT_SCALE_RATIO -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash",
"showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites", "showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites",
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE, "viewPagerOffScreenLimit" -> { "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE, "viewPagerOffScreenLimit" -> {
@ -175,7 +181,8 @@ class PreferencesActivity :
override fun androidInjector() = androidInjector override fun androidInjector() = androidInjector
companion object { companion object {
@Suppress("unused")
private const val TAG = "PreferencesActivity"
const val GENERAL_PREFERENCES = 0 const val GENERAL_PREFERENCES = 0
const val ACCOUNT_PREFERENCES = 1 const val ACCOUNT_PREFERENCES = 1
const val NOTIFICATION_PREFERENCES = 2 const val NOTIFICATION_PREFERENCES = 2

View File

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.sliderPreference
import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.deserialize
@ -105,6 +106,19 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceDataStore = localeManager preferenceDataStore = localeManager
} }
sliderPreference {
key = PrefKeys.UI_TEXT_SCALE_RATIO
setDefaultValue(100F)
valueTo = 150F
valueFrom = 50F
stepSize = 5F
setTitle(R.string.pref_ui_text_size)
format = "%.0f%%"
decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out)
incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
}
listPreference { listPreference {
setDefaultValue("medium") setDefaultValue("medium")
setEntries(R.array.post_text_size_names) setEntries(R.array.post_text_size_names)

View File

@ -47,7 +47,6 @@ import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class ScheduledStatusActivity : class ScheduledStatusActivity :
@ -102,15 +101,7 @@ class ScheduledStatusActivity :
binding.errorMessageView.show() binding.errorMessageView.show()
val errorState = loadState.refresh as LoadState.Error val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) { binding.errorMessageView.setup(errorState.error) { refreshStatuses() }
binding.errorMessageView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshStatuses()
}
} else {
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshStatuses()
}
}
} }
if (loadState.refresh != LoadState.Loading) { if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false

View File

@ -40,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import javax.inject.Inject import javax.inject.Inject
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, SearchView.OnQueryTextListener {
@Inject @Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any> lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -92,8 +92,6 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
searchViewMenuItem.expandActionView() searchViewMenuItem.expandActionView()
val searchView = searchViewMenuItem.actionView as SearchView val searchView = searchViewMenuItem.actionView as SearchView
setupSearchView(searchView) setupSearchView(searchView)
searchView.setQuery(viewModel.currentQuery, false)
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean { override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
@ -152,9 +150,23 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt() val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt()
searchView.maxWidth = pxScreenWidth - pxBuffer 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() 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 override fun androidInjector() = androidInjector
companion object { companion object {

View File

@ -49,12 +49,10 @@ class SearchViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
var currentQuery: String = "" var currentQuery: String = ""
var currentSearchFieldContent: String? = null
var activeAccount: AccountEntity? val activeAccount: AccountEntity?
get() = accountManager.activeAccount get() = accountManager.activeAccount
set(value) {
accountManager.activeAccount = value
}
val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false

View File

@ -15,15 +15,28 @@
package com.keylesspalace.tusky.components.search.fragments package com.keylesspalace.tusky.components.search.fragments
import android.os.Bundle
import android.view.View
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<TimelineAccount>() { class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.searchRecyclerView.addItemDecoration(
DividerItemDecoration(
binding.searchRecyclerView.context,
DividerItemDecoration.VERTICAL
)
)
}
override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> { override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)

View File

@ -13,7 +13,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -129,7 +128,6 @@ abstract class SearchFragment<T : Any> :
} }
private fun initAdapter() { private fun initAdapter() {
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
adapter = createAdapter() adapter = createAdapter()
binding.searchRecyclerView.adapter = adapter binding.searchRecyclerView.adapter = adapter

View File

@ -15,8 +15,11 @@
package com.keylesspalace.tusky.components.search.fragments package com.keylesspalace.tusky.components.search.fragments
import android.os.Bundle
import android.view.View
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -26,6 +29,16 @@ class SearchHashtagsFragment : SearchFragment<HashTag>() {
override val data: Flow<PagingData<HashTag>> override val data: Flow<PagingData<HashTag>>
get() = viewModel.hashtagsFlow get() = viewModel.hashtagsFlow
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.searchRecyclerView.addItemDecoration(
DividerItemDecoration(
binding.searchRecyclerView.context,
DividerItemDecoration.VERTICAL
)
)
}
override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this) override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this)
companion object { companion object {

View File

@ -82,7 +82,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -249,16 +248,7 @@ class TimelineFragment :
} }
is LoadState.Error -> { is LoadState.Error -> {
binding.statusView.show() binding.statusView.show()
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() }
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
onRefresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
onRefresh()
}
}
} }
is LoadState.Loading -> { is LoadState.Loading -> {
binding.progressBar.show() binding.progressBar.show()

View File

@ -51,14 +51,11 @@ import com.keylesspalace.tusky.util.EmptyPagingSource
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
/** /**
* TimelineViewModel that caches all statuses in a local database * TimelineViewModel that caches all statuses in a local database
@ -108,16 +105,6 @@ class CachedTimelineViewModel @Inject constructor(
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
init {
viewModelScope.launch {
delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh
accountManager.activeAccount?.id?.let { accountId ->
db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE)
db.timelineDao().cleanupAccounts(accountId)
}
}
}
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
// handled by CacheUpdater // handled by CacheUpdater
} }

View File

@ -19,23 +19,19 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.commit import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.databinding.ActivityTrendingBinding import com.keylesspalace.tusky.databinding.ActivityTrendingBinding
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import javax.inject.Inject import javax.inject.Inject
class TrendingActivity : BottomSheetActivity(), HasAndroidInjector { class TrendingActivity : BaseActivity(), HasAndroidInjector {
@Inject @Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any> lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var eventHub: EventHub
private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate) private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -44,10 +40,8 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
val title = getString(R.string.title_public_trending_hashtags)
supportActionBar?.run { supportActionBar?.run {
setTitle(title) setTitle(R.string.title_public_trending_hashtags)
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
@ -63,10 +57,6 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
companion object { companion object {
const val TAG = "TrendingActivity" fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java)
@JvmStatic
fun getIntent(context: Context) =
Intent(context, TrendingActivity::class.java)
} }
} }

View File

@ -20,15 +20,12 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.adapter.TrendingDateViewHolder
import com.keylesspalace.tusky.adapter.TrendingTagViewHolder
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.viewdata.TrendingViewData import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingAdapter( class TrendingAdapter(
private val trendingListener: LinkListener private val onViewTag: (String) -> Unit
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) { ) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {
init { init {
@ -42,7 +39,6 @@ class TrendingAdapter(
ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context)) ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context))
TrendingTagViewHolder(binding) TrendingTagViewHolder(binding)
} }
else -> { else -> {
val binding = val binding =
ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context)) ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context))
@ -52,38 +48,15 @@ class TrendingAdapter(
} }
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(viewHolder, position, null) when (val viewData = getItem(position)) {
}
override fun onBindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>
) {
bindViewHolder(viewHolder, position, payloads)
}
private fun bindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>?
) {
when (val header = getItem(position)) {
is TrendingViewData.Tag -> { is TrendingViewData.Tag -> {
val maxTrendingValue = currentList
.flatMap { trendingViewData ->
trendingViewData.asTagOrNull()?.tag?.history.orEmpty()
}
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
val holder = viewHolder as TrendingTagViewHolder val holder = viewHolder as TrendingTagViewHolder
holder.setup(header, maxTrendingValue, trendingListener) holder.setup(viewData, onViewTag)
} }
is TrendingViewData.Header -> { is TrendingViewData.Header -> {
val holder = viewHolder as TrendingDateViewHolder val holder = viewHolder as TrendingDateViewHolder
holder.setup(header.start, header.end) holder.setup(viewData.start, viewData.end)
} }
} }
} }
@ -112,14 +85,7 @@ class TrendingAdapter(
oldItem: TrendingViewData, oldItem: TrendingViewData,
newItem: TrendingViewData newItem: TrendingViewData
): Boolean { ): Boolean {
return false return oldItem == newItem
}
override fun getChangePayload(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Any? {
return null
} }
} }
} }

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.components.trending
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R

View File

@ -15,17 +15,14 @@
package com.keylesspalace.tusky.components.trending package com.keylesspalace.tusky.components.trending
import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager import android.view.accessibility.AccessibilityManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
@ -33,18 +30,14 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
@ -56,48 +49,20 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class TrendingFragment : class TrendingFragment :
Fragment(), Fragment(R.layout.fragment_trending),
OnRefreshListener, OnRefreshListener,
LinkListener,
Injectable, Injectable,
ReselectableFragment, ReselectableFragment,
RefreshableFragment { RefreshableFragment {
private lateinit var bottomSheetActivity: BottomSheetActivity
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@Inject private val viewModel: TrendingViewModel by viewModels { viewModelFactory }
lateinit var accountManager: AccountManager
@Inject
lateinit var eventHub: EventHub
private val viewModel: TrendingViewModel by lazy {
ViewModelProvider(this, viewModelFactory)[TrendingViewModel::class.java]
}
private val binding by viewBinding(FragmentTrendingBinding::bind) private val binding by viewBinding(FragmentTrendingBinding::bind)
private lateinit var adapter: TrendingAdapter private val adapter = TrendingAdapter(::onViewTag)
override fun onAttach(context: Context) {
super.onAttach(context)
bottomSheetActivity = if (context is BottomSheetActivity) {
context
} else {
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = TrendingAdapter(
this
)
}
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
@ -106,14 +71,6 @@ class TrendingFragment :
setupLayoutManager(columnCount) setupLayoutManager(columnCount)
} }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_trending, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setupSwipeRefreshLayout() setupSwipeRefreshLayout()
setupRecyclerView() setupRecyclerView()
@ -175,25 +132,19 @@ class TrendingFragment :
} }
override fun onRefresh() { override fun onRefresh() {
viewModel.invalidate() viewModel.invalidate(true)
} }
override fun onViewUrl(url: String, text: String) { fun onViewTag(tag: String) {
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER, text) (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewTag(tag: String) {
bottomSheetActivity.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewAccount(id: String) {
bottomSheetActivity.viewAccount(id)
} }
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) { private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
Log.d(TAG, uiState.loadingState.name)
when (uiState.loadingState) { when (uiState.loadingState) {
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState() TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingViewModel.LoadingState.LOADING -> applyLoadingState() TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError() TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError() TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
@ -203,8 +154,9 @@ class TrendingFragment :
private fun applyLoadedState(viewData: List<TrendingViewData>) { private fun applyLoadedState(viewData: List<TrendingViewData>) {
clearLoadingState() clearLoadingState()
adapter.submitList(viewData)
if (viewData.isEmpty()) { if (viewData.isEmpty()) {
adapter.submitList(emptyList())
binding.recyclerView.hide() binding.recyclerView.hide()
binding.messageView.show() binding.messageView.show()
binding.messageView.setup( binding.messageView.setup(
@ -213,16 +165,16 @@ class TrendingFragment :
null null
) )
} else { } else {
val viewDataWithDates = listOf(viewData.first().asHeaderOrNull()) + viewData
adapter.submitList(viewDataWithDates)
binding.recyclerView.show() binding.recyclerView.show()
binding.messageView.hide() binding.messageView.hide()
} }
binding.progressBar.hide() binding.progressBar.hide()
} }
private fun applyRefreshingState() {
binding.swipeRefreshLayout.isRefreshing = true
}
private fun applyLoadingState() { private fun applyLoadingState() {
binding.recyclerView.hide() binding.recyclerView.hide()
binding.messageView.hide() binding.messageView.hide()
@ -297,8 +249,6 @@ class TrendingFragment :
companion object { companion object {
private const val TAG = "TrendingFragment" private const val TAG = "TrendingFragment"
fun newInstance(): TrendingFragment { fun newInstance() = TrendingFragment()
return TrendingFragment()
}
} }
} }

View File

@ -0,0 +1,57 @@
/* 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.trending
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.util.formatNumber
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingTagViewHolder(
private val binding: ItemTrendingCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun setup(
tagViewData: TrendingViewData.Tag,
onViewTag: (String) -> Unit
) {
binding.tag.text = binding.root.context.getString(R.string.title_tag, tagViewData.name)
binding.graph.maxTrendingValue = tagViewData.maxTrendingValue
binding.graph.primaryLineData = tagViewData.usage
binding.graph.secondaryLineData = tagViewData.accounts
binding.totalUsage.text = formatNumber(tagViewData.usage.sum(), 1000)
val totalAccounts = tagViewData.accounts.sum()
binding.totalAccounts.text = formatNumber(totalAccounts, 1000)
binding.currentUsage.text = tagViewData.usage.last().toString()
binding.currentAccounts.text = tagViewData.usage.last().toString()
itemView.setOnClickListener {
onViewTag(tagViewData.name)
}
itemView.contentDescription =
itemView.context.getString(
R.string.accessibility_talking_about_tag,
totalAccounts,
tagViewData.name
)
}
}

View File

@ -15,11 +15,15 @@
package com.keylesspalace.tusky.components.trending.viewmodel package com.keylesspalace.tusky.components.trending.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.end
import com.keylesspalace.tusky.entity.start
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData import com.keylesspalace.tusky.viewdata.TrendingViewData
@ -28,7 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okio.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class TrendingViewModel @Inject constructor( class TrendingViewModel @Inject constructor(
@ -36,7 +40,7 @@ class TrendingViewModel @Inject constructor(
private val eventHub: EventHub private val eventHub: EventHub
) : ViewModel() { ) : ViewModel() {
enum class LoadingState { enum class LoadingState {
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER
} }
data class TrendingUiState( data class TrendingUiState(
@ -67,37 +71,47 @@ class TrendingViewModel @Inject constructor(
* *
* A tag is excluded if it is filtered by the user on their home timeline. * A tag is excluded if it is filtered by the user on their home timeline.
*/ */
fun invalidate() = viewModelScope.launch { fun invalidate(refresh: Boolean = false) = viewModelScope.launch {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING) if (refresh) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING)
try { } else {
val deferredFilters = async { mastodonApi.getFilters() } _uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
val response = mastodonApi.trendingTags()
if (!response.isSuccessful) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
return@launch
}
val homeFilters = deferredFilters.await().getOrNull()?.filter {
it.context.contains(Filter.Kind.HOME.kind)
}
val tags = response.body()!!
.filter {
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(it.name, ignoreCase = true) }
} ?: false
}
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.map { it.toViewData() }
.asReversed()
_uiState.value = TrendingUiState(tags, LoadingState.LOADED)
} catch (e: IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
} catch (e: Exception) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
} }
val deferredFilters = async { mastodonApi.getFilters() }
mastodonApi.trendingTags().fold(
{ tagResponse ->
val firstTag = tagResponse.firstOrNull()
_uiState.value = if (firstTag == null) {
TrendingUiState(emptyList(), LoadingState.LOADED)
} else {
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
}
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData()
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
TrendingUiState(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)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
}
}
)
} }
companion object { companion object {

View File

@ -61,7 +61,6 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class ViewThreadFragment : class ViewThreadFragment :
@ -203,21 +202,7 @@ class ViewThreadFragment :
binding.recyclerView.hide() binding.recyclerView.hide()
binding.statusView.show() binding.statusView.show()
if (uiState.throwable is IOException) { binding.statusView.setup(uiState.throwable) { viewModel.retry(thisThreadsStatusId) }
binding.statusView.setup(
R.drawable.elephant_offline,
R.string.error_network
) {
viewModel.retry(thisThreadsStatusId)
}
} else {
binding.statusView.setup(
R.drawable.elephant_error,
R.string.error_generic
) {
viewModel.retry(thisThreadsStatusId)
}
}
} }
is ThreadUiState.Success -> { is ThreadUiState.Success -> {
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) { if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {

View File

@ -1,8 +1,6 @@
package com.keylesspalace.tusky.components.viewthread.edits package com.keylesspalace.tusky.components.viewthread.edits
import android.content.Context import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface.DEFAULT_BOLD import android.graphics.Typeface.DEFAULT_BOLD
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@ -11,7 +9,9 @@ import android.text.Html
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.style.ReplacementSpan import android.text.TextPaint
import android.text.style.CharacterStyle
import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -33,11 +33,9 @@ import com.keylesspalace.tusky.util.aspectRatios
import com.keylesspalace.tusky.util.decodeBlurHash import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.toViewData import com.keylesspalace.tusky.viewdata.toViewData
import org.xml.sax.XMLReader import org.xml.sax.XMLReader
@ -52,13 +50,28 @@ class ViewEditsAdapter(
private val absoluteTimeFormatter = AbsoluteTimeFormatter() private val absoluteTimeFormatter = AbsoluteTimeFormatter()
/** Size of large text in this theme, in px */
var largeTextSizePx: Float = 0f
/** Size of medium text in this theme, in px */
var mediumTextSizePx: Float = 0f
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int viewType: Int
): BindingHolder<ItemStatusEditBinding> { ): BindingHolder<ItemStatusEditBinding> {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true binding.statusEditMediaPreview.clipToOutline = true
val typedValue = TypedValue()
val context = binding.root.context
val displayMetrics = context.resources.displayMetrics
context.theme.resolveAttribute(R.attr.status_text_large, typedValue, true)
largeTextSizePx = typedValue.getDimension(displayMetrics)
context.theme.resolveAttribute(R.attr.status_text_medium, typedValue, true)
mediumTextSizePx = typedValue.getDimension(displayMetrics)
return BindingHolder(binding) return BindingHolder(binding)
} }
@ -69,24 +82,26 @@ class ViewEditsAdapter(
val context = binding.root.context val context = binding.root.context
val avatarRadius: Int = context.resources val infoStringRes = if (position == edits.lastIndex) {
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars)
val infoStringRes = if (position == edits.size - 1) {
R.string.status_created_info R.string.status_created_info
} else { } else {
R.string.status_edit_info R.string.status_edit_info
} }
// Show the most recent version of the status using large text to make it clearer for
// the user, and for similarity with thread view.
val variableTextSize = if (position == edits.lastIndex) {
mediumTextSizePx
} else {
largeTextSizePx
}
binding.statusEditContentWarningDescription.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
val timestamp = absoluteTimeFormatter.format(edit.createdAt, false) val timestamp = absoluteTimeFormatter.format(edit.createdAt, false)
binding.statusEditInfo.text = context.getString( binding.statusEditInfo.text = context.getString(infoStringRes, timestamp)
infoStringRes,
edit.account.name.unicodeWrap(),
timestamp
).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis)
if (edit.spoilerText.isEmpty()) { if (edit.spoilerText.isEmpty()) {
binding.statusEditContentWarningDescription.hide() binding.statusEditContentWarningDescription.hide()
@ -198,6 +213,11 @@ class ViewEditsAdapter(
} }
override fun getItemCount() = edits.size override fun getItemCount() = edits.size
companion object {
private const val VIEW_TYPE_EDITS_NEWEST = 0
private const val VIEW_TYPE_EDITS = 1
}
} }
/** /**
@ -266,98 +286,31 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler {
} }
} }
/**
* A span that draws text with additional padding at the start/end of the text. The padding
* is the width of [separator].
*
* Note: The separator string is not included in the final text, so it will not be included
* if the user cuts or copies the text.
*/
open class LRPaddedSpan(val separator: String = " ") : ReplacementSpan() {
/** The width of the separator string, used as padding */
var paddingWidth = 0f
/** Measured width of the span */
var spanWidth = 0f
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
paddingWidth = paint.measureText(separator, 0, separator.length)
spanWidth = (paddingWidth * 2) + paint.measureText(text, start, end)
return spanWidth.toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawText(text?.subSequence(start, end).toString(), x + paddingWidth, y.toFloat(), paint)
}
}
/** Span that signifies deleted text */ /** Span that signifies deleted text */
class DeletedTextSpan(context: Context) : LRPaddedSpan() { class DeletedTextSpan(context: Context) : CharacterStyle() {
private val bgPaint = Paint() private var bgColor: Int
val radius: Float
init { init {
bgPaint.color = context.getColor(R.color.view_edits_background_delete) bgColor = context.getColor(R.color.view_edits_background_delete)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
} }
override fun draw( override fun updateDrawState(tp: TextPaint) {
canvas: Canvas, tp.bgColor = bgColor
text: CharSequence?, tp.isStrikeThruText = true
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
paint.isStrikeThruText = true
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
} }
} }
/** Span that signifies inserted text */ /** Span that signifies inserted text */
class InsertedTextSpan(context: Context) : LRPaddedSpan() { class InsertedTextSpan(context: Context) : CharacterStyle() {
val bgPaint = Paint() private var bgColor: Int
val radius: Float
init { init {
bgPaint.color = context.getColor(R.color.view_edits_background_insert) bgColor = context.getColor(R.color.view_edits_background_insert)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
} }
override fun draw( override fun updateDrawState(tp: TextPaint) {
canvas: Canvas, tp.bgColor = bgColor
text: CharSequence?, tp.typeface = DEFAULT_BOLD
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
paint.typeface = DEFAULT_BOLD
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
} }
} }

View File

@ -37,24 +37,26 @@ import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class ViewEditsFragment : class ViewEditsFragment :
Fragment(R.layout.fragment_view_thread), Fragment(R.layout.fragment_view_edits),
LinkListener, LinkListener,
OnRefreshListener, OnRefreshListener,
MenuProvider, MenuProvider,
@ -65,7 +67,7 @@ class ViewEditsFragment :
private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory } private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentViewThreadBinding::bind) private val binding by viewBinding(FragmentViewEditsBinding::bind)
private lateinit var statusId: String private lateinit var statusId: String
@ -88,6 +90,7 @@ class ViewEditsFragment :
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
val avatarRadius: Int = requireContext().resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { uiState -> viewModel.uiState.collect { uiState ->
@ -107,13 +110,17 @@ class ViewEditsFragment :
binding.statusView.show() binding.statusView.show()
binding.initialProgressBar.hide() binding.initialProgressBar.hide()
if (uiState.throwable is IOException) { when (uiState.throwable) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { is ViewEditsViewModel.MissingEditsException -> {
viewModel.loadEdits(statusId, force = true) binding.statusView.setup(
R.drawable.elephant_friend_empty,
R.string.error_missing_edits
)
} }
} else { else -> {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { binding.statusView.setup(uiState.throwable) {
viewModel.loadEdits(statusId, force = true) viewModel.loadEdits(statusId, force = true)
}
} }
} }
} }
@ -130,6 +137,15 @@ class ViewEditsFragment :
useBlurhash = useBlurhash, useBlurhash = useBlurhash,
listener = this@ViewEditsFragment listener = this@ViewEditsFragment
) )
// Focus on the most recent version
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(0)
val account = uiState.edits.first().account
loadAvatar(account.avatar, binding.statusAvatar, avatarRadius, animateAvatars)
binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis)
binding.statusUsername.text = account.username
} }
} }
} }

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.viewthread.edits
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL
import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.entity.StatusEdit
@ -48,6 +48,9 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial) private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow() val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
/** The API call to fetch edit history returned less than two items */
object MissingEditsException : Exception()
fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) { fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) {
if (!force && _uiState.value !is EditsUiState.Initial) return if (!force && _uiState.value !is EditsUiState.Initial) return
@ -58,66 +61,68 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
} }
viewModelScope.launch { viewModelScope.launch {
api.statusEdits(statusId).fold( val edits = api.statusEdits(statusId).getOrElse {
{ edits -> _uiState.value = EditsUiState.Error(it)
// Diff each status' content against the previous version, producing new return@launch
// content with additional `ins` or `del` elements marking inserted or }
// deleted content.
//
// This can be CPU intensive depending on the number of edits and the size
// of each, so don't run this on Dispatchers.Main.
viewModelScope.launch(Dispatchers.Default) {
val sortedEdits = edits.sortedBy { it.createdAt }
.reversed()
.toMutableList()
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver") // `edits` might have fewer than the minimum number of entries because of
val loader = SAXLoader() // https://github.com/mastodon/mastodon/issues/25398.
loader.config = DiffConfig( if (edits.size < 2) {
false, _uiState.value = EditsUiState.Error(MissingEditsException)
WhiteSpaceProcessing.PRESERVE, return@launch
TextGranularity.SPACE_WORD }
// Diff each status' content against the previous version, producing new
// content with additional `ins` or `del` elements marking inserted or
// deleted content.
//
// This can be CPU intensive depending on the number of edits and the size
// of each, so don't run this on Dispatchers.Main.
viewModelScope.launch(Dispatchers.Default) {
val sortedEdits = edits.sortedBy { it.createdAt }
.reversed()
.toMutableList()
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
val loader = SAXLoader()
loader.config = DiffConfig(
false,
WhiteSpaceProcessing.PRESERVE,
TextGranularity.SPACE_WORD
)
val processor = OptimisticXMLProcessor()
processor.setCoalesce(true)
val output = HtmlDiffOutput()
try {
// The XML processor expects `br` to be closed
var currentContent =
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
var previousContent =
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
for (i in 1 until sortedEdits.size) {
processor.diff(previousContent, currentContent, output)
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
content = output.xml.toString()
) )
val processor = OptimisticXMLProcessor()
processor.setCoalesce(true)
val output = HtmlDiffOutput()
try { if (i < sortedEdits.size - 1) {
// The XML processor expects `br` to be closed currentContent = previousContent
var currentContent = previousContent = loader.load(
loader.load(sortedEdits[0].content.replace("<br>", "<br/>")) sortedEdits[i + 1].content.replace("<br>", "<br/>")
var previousContent = )
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
for (i in 1 until sortedEdits.size) {
processor.diff(previousContent, currentContent, output)
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
content = output.xml.toString()
)
if (i < sortedEdits.size - 1) {
currentContent = previousContent
previousContent = loader.load(
sortedEdits[i + 1].content.replace(
"<br>",
"<br/>"
)
)
}
}
_uiState.value = EditsUiState.Success(sortedEdits)
} catch (_: LoadingException) {
// Something failed parsing the XML from the server. Rather than
// show an error just return the sorted edits so the user can at
// least visually scan the differences.
_uiState.value = EditsUiState.Success(sortedEdits)
} }
} }
}, _uiState.value = EditsUiState.Success(sortedEdits)
{ throwable -> } catch (_: LoadingException) {
_uiState.value = EditsUiState.Error(throwable) // Something failed parsing the XML from the server. Rather than
// show an error just return the sorted edits so the user can at
// least visually scan the differences.
_uiState.value = EditsUiState.Success(sortedEdits)
} }
) }
} }
} }

View File

@ -37,9 +37,11 @@ class AccountManager @Inject constructor(db: AppDatabase) {
@Volatile @Volatile
var activeAccount: AccountEntity? = null var activeAccount: AccountEntity? = null
private set
var accounts: MutableList<AccountEntity> = mutableListOf() var accounts: MutableList<AccountEntity> = mutableListOf()
private set private set
private val accountDao: AccountDao = db.accountDao() private val accountDao: AccountDao = db.accountDao()
init { init {

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.db; package com.keylesspalace.tusky.db;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.AutoMigration; import androidx.room.AutoMigration;
import androidx.room.Database; import androidx.room.Database;
import androidx.room.DeleteColumn; import androidx.room.DeleteColumn;
@ -50,11 +51,11 @@ import java.io.File;
) )
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); @NonNull public abstract AccountDao accountDao();
public abstract InstanceDao instanceDao(); @NonNull public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao(); @NonNull public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao(); @NonNull public abstract TimelineDao timelineDao();
public abstract DraftDao draftDao(); @NonNull public abstract DraftDao draftDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) { public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override @Override
@ -386,7 +387,7 @@ public abstract class AppDatabase extends RoomDatabase {
private final File oldDraftDirectory; private final File oldDraftDirectory;
public Migration25_26(File oldDraftDirectory) { public Migration25_26(@Nullable File oldDraftDirectory) {
super(25, 26); super(25, 26);
this.oldDraftDirectory = oldDraftDirectory; this.oldDraftDirectory = oldDraftDirectory;
} }

View File

@ -106,8 +106,8 @@ class Converters @Inject constructor(
} }
@TypeConverter @TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): ArrayList<Attachment>? { fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<ArrayList<Attachment>>() {}.type) return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type)
} }
@TypeConverter @TypeConverter

View File

@ -29,12 +29,14 @@ import javax.inject.Singleton
@Component( @Component(
modules = [ modules = [
AppModule::class, AppModule::class,
CoroutineScopeModule::class,
NetworkModule::class, NetworkModule::class,
AndroidSupportInjectionModule::class, AndroidSupportInjectionModule::class,
ActivitiesModule::class, ActivitiesModule::class,
ServicesModule::class, ServicesModule::class,
BroadcastReceiverModule::class, BroadcastReceiverModule::class,
ViewModelModule::class ViewModelModule::class,
WorkerModule::class
] ]
) )
interface AppComponent { interface AppComponent {

View File

@ -0,0 +1,44 @@
/*
* 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.di
import dagger.Module
import dagger.Provides
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
/**
* Scope for potentially long-running tasks that should outlive the viewmodel that
* started them. For example, if the API call to bookmark a status is taking a long
* time, that call should not be cancelled because the user has navigated away from
* the viewmodel that made the call.
*
* @see https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen
*/
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class ApplicationScope
@Module
class CoroutineScopeModule {
@ApplicationScope
@Provides
fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

View File

@ -0,0 +1,45 @@
/*
* 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.di
import androidx.work.ListenableWorker
import com.keylesspalace.tusky.worker.ChildWorkerFactory
import com.keylesspalace.tusky.worker.NotificationWorker
import com.keylesspalace.tusky.worker.PruneCacheWorker
import dagger.Binds
import dagger.MapKey
import dagger.Module
import dagger.multibindings.IntoMap
import kotlin.reflect.KClass
@Retention(AnnotationRetention.RUNTIME)
@MapKey
annotation class WorkerKey(val value: KClass<out ListenableWorker>)
@Module
abstract class WorkerModule {
@Binds
@IntoMap
@WorkerKey(NotificationWorker::class)
internal abstract fun bindNotificationWorkerFactory(worker: NotificationWorker.Factory): ChildWorkerFactory
@Binds
@IntoMap
@WorkerKey(PruneCacheWorker::class)
internal abstract fun bindPruneCacheWorkerFactory(worker: PruneCacheWorker.Factory): ChildWorkerFactory
}

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.ArrayList
import java.util.Date import java.util.Date
data class DeletedStatus( data class DeletedStatus(
@ -25,7 +24,7 @@ data class DeletedStatus(
@SerializedName("spoiler_text") val spoilerText: String, @SerializedName("spoiler_text") val spoilerText: String,
val visibility: Status.Visibility, val visibility: Status.Visibility,
val sensitive: Boolean, val sensitive: Boolean,
@SerializedName("media_attachments") val attachments: ArrayList<Attachment>?, @SerializedName("media_attachments") val attachments: List<Attachment>?,
val poll: Poll?, val poll: Poll?,
@SerializedName("created_at") val createdAt: Date, @SerializedName("created_at") val createdAt: Date,
val language: String? val language: String?

View File

@ -41,7 +41,7 @@ data class Status(
val sensitive: Boolean, val sensitive: Boolean,
@SerializedName("spoiler_text", alternate = ["summary"]) val spoilerText: String, @SerializedName("spoiler_text", alternate = ["summary"]) val spoilerText: String,
val visibility: Visibility, val visibility: Visibility,
@SerializedName("media_attachments", alternate = ["attachment"]) val attachments: ArrayList<Attachment>, @SerializedName("media_attachments", alternate = ["attachment"]) val attachments: List<Attachment>,
@SerializedName("mentions", alternate = ["tag"]) val mentions: List<Mention>, @SerializedName("mentions", alternate = ["tag"]) val mentions: List<Mention>,
val tags: List<HashTag>?, val tags: List<HashTag>?,
val application: Application?, val application: Application?,

View File

@ -21,15 +21,13 @@ import java.util.Date
* Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags * Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
* *
* @param name The name of the hashtag (after the #). The "caturday" in "#caturday". * @param name The name of the hashtag (after the #). The "caturday" in "#caturday".
* @param url The URL to your mastodon instance list for this hashtag. * (@param url The URL to your mastodon instance list for this hashtag.)
* @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag. * @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag.
* @param following This is not listed in the APIs at the time of writing, but an instance is delivering it. * (@param following This is not listed in the APIs at the time of writing, but an instance is delivering it.)
*/ */
data class TrendingTag( data class TrendingTag(
val name: String, val name: String,
val url: String, val history: List<TrendingTagHistory>
val history: List<TrendingTagHistory>,
val following: Boolean
) )
/** /**

View File

@ -26,6 +26,7 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.core.os.BundleCompat
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.engine.GlideException
@ -92,7 +93,7 @@ class ViewImageFragment : ViewMediaFragment() {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val arguments = this.requireArguments() val arguments = this.requireArguments()
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT) val attachment = BundleCompat.getParcelable(arguments, ARG_ATTACHMENT, Attachment::class.java)
this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION) this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)
val url: String? val url: String?
var description: String? = null var description: String? = null

View File

@ -782,5 +782,5 @@ interface MastodonApi {
suspend fun unfollowTag(@Path("name") name: String): NetworkResult<HashTag> suspend fun unfollowTag(@Path("name") name: String): NetworkResult<HashTag>
@GET("api/v1/trends/tags") @GET("api/v1/trends/tags")
suspend fun trendingTags(): Response<List<TrendingTag>> suspend fun trendingTags(): NetworkResult<List<TrendingTag>>
} }

View File

@ -20,11 +20,11 @@ import android.content.Intent
import android.util.Log import android.util.Log
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.NotificationWorker
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.worker.NotificationWorker
import dagger.android.AndroidInjection import dagger.android.AndroidInjection
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope

View File

@ -16,6 +16,7 @@ import android.util.Log
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.IntentCompat
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
@ -83,7 +84,7 @@ class SendStatusService : Service(), Injectable {
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_STATUS)) { if (intent.hasExtra(KEY_STATUS)) {
val statusToSend: StatusToSend = intent.getParcelableExtra(KEY_STATUS) val statusToSend: StatusToSend = IntentCompat.getParcelableExtra(intent, KEY_STATUS, StatusToSend::class.java)
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -1,15 +1,21 @@
package com.keylesspalace.tusky.settings package com.keylesspalace.tusky.settings
import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceDataStore
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class AccountPreferenceHandler( class AccountPreferenceDataStore @Inject constructor(
private val account: AccountEntity,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val dispatchEvent: (PreferenceChangedEvent) -> Unit private val eventHub: EventHub,
@ApplicationScope private val externalScope: CoroutineScope
) : PreferenceDataStore() { ) : PreferenceDataStore() {
private val account: AccountEntity = accountManager.activeAccount!!
override fun getBoolean(key: String, defValue: Boolean): Boolean { override fun getBoolean(key: String, defValue: Boolean): Boolean {
return when (key) { return when (key) {
@ -29,6 +35,8 @@ class AccountPreferenceHandler(
accountManager.saveAccount(account) accountManager.saveAccount(account)
dispatchEvent(PreferenceChangedEvent(key)) externalScope.launch {
eventHub.dispatch(PreferenceChangedEvent(key))
}
} }
} }

View File

@ -111,4 +111,7 @@ object PrefKeys {
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
} }

View File

@ -14,6 +14,7 @@ import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference import androidx.preference.SwitchPreference
import com.keylesspalace.tusky.view.SliderPreference
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
class PreferenceParent( class PreferenceParent(
@ -43,6 +44,15 @@ inline fun <A> PreferenceParent.emojiPreference(activity: A, builder: EmojiPicke
return pref return pref
} }
inline fun PreferenceParent.sliderPreference(
builder: SliderPreference.() -> Unit
): SliderPreference {
val pref = SliderPreference(context)
builder(pref)
addPref(pref)
return pref
}
inline fun PreferenceParent.switchPreference( inline fun PreferenceParent.switchPreference(
builder: SwitchPreference.() -> Unit builder: SwitchPreference.() -> Unit
): SwitchPreference { ): SwitchPreference {

View File

@ -84,11 +84,26 @@ class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan()
imageDrawable?.let { drawable -> imageDrawable?.let { drawable ->
canvas.save() canvas.save()
val emojiSize = (paint.textSize * 1.1).toInt() // start with a width relative to the text size
drawable.setBounds(0, 0, emojiSize, emojiSize) var emojiWidth = paint.textSize * 1.1
var transY = bottom - drawable.bounds.bottom // calculate the height, keeping the aspect ratio correct
transY -= paint.fontMetricsInt.descent / 2 val drawableWidth = drawable.intrinsicWidth
val drawableHeight = drawable.intrinsicHeight
var emojiHeight = emojiWidth / drawableWidth * drawableHeight
// how much vertical space there is draw the emoji
val drawableSpace = (bottom - top).toDouble()
// in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio
if (emojiHeight > drawableSpace) {
emojiWidth *= drawableSpace / emojiHeight
emojiHeight = drawableSpace
}
drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt())
// vertically center the emoji in the line
val transY = top + (drawableSpace / 2 - emojiHeight / 2)
canvas.translate(x, transY.toFloat()) canvas.translate(x, transY.toFloat())
drawable.draw(canvas) drawable.draw(canvas)

View File

@ -0,0 +1,69 @@
/*
* 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 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
/**
* Returns a flow that mirrors the original flow, but filters out values that occur within
* [timeout] of the previously emitted value. The first value is always emitted.
*
* Example:
*
* ```kotlin
* flow {
* emit(1)
* delay(90.milliseconds)
* emit(2)
* delay(90.milliseconds)
* emit(3)
* delay(1010.milliseconds)
* emit(4)
* delay(1010.milliseconds)
* emit(5)
* }.throttleFirst(1000.milliseconds)
* ```
*
* produces the following emissions.
*
* ```text
* 1, 4, 5
* ```
*
* @see kotlinx.coroutines.flow.debounce(Duration)
* @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
) = flow {
var marker: TimeMark? = null
collect {
if (marker == null || marker!!.elapsedNow() >= timeout) {
emit(it)
marker = timeSource.markNow()
}
}
}

View File

@ -2,25 +2,27 @@
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import java.text.DecimalFormat import java.text.NumberFormat
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.floor import kotlin.math.ln
import kotlin.math.log10
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.sign
val shortLetters = arrayOf(' ', 'K', 'M', 'B', 'T', 'P', 'E') private val numberFormatter: NumberFormat = NumberFormat.getInstance()
private val ln_1k = ln(1000.0)
fun shortNumber(number: Number): String { /**
val numberAsDouble = number.toDouble() * Format numbers according to the current locale. Numbers < min have
val nonNegativeValue = abs(numberAsDouble) * separators (',', '.', etc) inserted according to the locale.
var sign = "" *
if (numberAsDouble.sign < 0) { sign = "-" } * Numbers >= min are scaled down to that by multiples of 1,000, and
val value = floor(log10(nonNegativeValue)).toInt() * a suffix appropriate to the scaling is appended.
val base = value / 3 */
if (value >= 3 && base < shortLetters.size) { fun formatNumber(num: Long, min: Int = 100000): String {
return DecimalFormat("$sign#0.0").format(nonNegativeValue / 10.0.pow((base * 3).toDouble())) + shortLetters[base] val absNum = abs(num)
} else { if (absNum < min) return numberFormatter.format(num)
return DecimalFormat("$sign#,##0").format(nonNegativeValue)
} val exp = (ln(absNum.toDouble()) / ln_1k).toInt()
// Suffixes here are locale-agnostic
return String.format("%.1f%c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
} }

View File

@ -1,8 +1,11 @@
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.content.Context
import com.keylesspalace.tusky.R
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException
/** /**
* checks if this throwable indicates an error causes by a 4xx/5xx server response and * checks if this throwable indicates an error causes by a 4xx/5xx server response and
@ -24,3 +27,16 @@ fun Throwable.getServerErrorMessage(): String? {
} }
return null return null
} }
/** @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
}
/** @return A string error message for this throwable */
fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) {
is IOException -> context.getString(R.string.error_network)
else -> context.getString(R.string.error_generic)
}

View File

@ -40,7 +40,6 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData import com.keylesspalace.tusky.viewdata.TrendingViewData
@JvmName("statusToViewData")
fun Status.toViewData( fun Status.toViewData(
isShowingContent: Boolean, isShowingContent: Boolean,
isExpanded: Boolean, isExpanded: Boolean,
@ -56,7 +55,6 @@ fun Status.toViewData(
) )
} }
@JvmName("notificationToViewData")
fun Notification.toViewData( fun Notification.toViewData(
isShowingContent: Boolean, isShowingContent: Boolean,
isExpanded: Boolean, isExpanded: Boolean,
@ -71,9 +69,20 @@ fun Notification.toViewData(
) )
} }
@JvmName("tagToViewData") fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
fun TrendingTag.toViewData(): TrendingViewData.Tag { val maxTrendingValue = flatMap { tag -> tag.history }
return TrendingViewData.Tag( .mapNotNull { it.uses.toLongOrNull() }
tag = this .maxOrNull() ?: 1
)
return map { tag ->
val reversedHistory = tag.history.asReversed()
TrendingViewData.Tag(
name = tag.name,
usage = reversedHistory.mapNotNull { it.uses.toLongOrNull() },
accounts = reversedHistory.mapNotNull { it.accounts.toLongOrNull() },
maxTrendingValue = maxTrendingValue
)
}
} }

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.util
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
@ -66,3 +67,14 @@ fun ViewPager2.reduceSwipeSensitivity() {
Log.w("reduceSwipeSensitivity", e) Log.w("reduceSwipeSensitivity", e)
} }
} }
/**
* TextViews with an ancestor RecyclerView can forget that they are selectable. Toggling
* calls to [TextView.setTextIsSelectable] fixes this.
*
* @see https://issuetracker.google.com/issues/37095917
*/
fun TextView.fixTextSelection() {
setTextIsSelectable(false)
post { setTextIsSelectable(true) }
}

View File

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.view package com.keylesspalace.tusky.view
import android.content.Context import android.content.Context
import android.text.method.LinkMovementMethod
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
@ -12,6 +13,8 @@ import androidx.annotation.StringRes
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding
import com.keylesspalace.tusky.util.addDrawables import com.keylesspalace.tusky.util.addDrawables
import com.keylesspalace.tusky.util.getDrawableRes
import com.keylesspalace.tusky.util.getErrorString
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
/** /**
@ -34,16 +37,27 @@ class BackgroundMessageView @JvmOverloads constructor(
} }
} }
fun setup(throwable: Throwable, listener: ((v: View) -> Unit)? = null) {
setup(throwable.getDrawableRes(), throwable.getErrorString(context), listener)
}
fun setup(
@DrawableRes imageRes: Int,
@StringRes messageRes: Int,
clickListener: ((v: View) -> Unit)? = null
) = setup(imageRes, context.getString(messageRes), clickListener)
/** /**
* Setup image, message and button. * Setup image, message and button.
* If [clickListener] is `null` then the button will be hidden. * If [clickListener] is `null` then the button will be hidden.
*/ */
fun setup( fun setup(
@DrawableRes imageRes: Int, @DrawableRes imageRes: Int,
@StringRes messageRes: Int, message: String,
clickListener: ((v: View) -> Unit)? = null clickListener: ((v: View) -> Unit)? = null
) { ) {
binding.messageTextView.setText(messageRes) binding.messageTextView.text = message
binding.messageTextView.movementMethod = LinkMovementMethod.getInstance()
binding.imageView.setImageResource(imageRes) binding.imageView.setImageResource(imageRes)
binding.button.setOnClickListener(clickListener) binding.button.setOnClickListener(clickListener)
binding.button.visible(clickListener != null) binding.button.visible(clickListener != null)

View File

@ -22,10 +22,9 @@ import android.graphics.Path
import android.graphics.PathMeasure import android.graphics.PathMeasure
import android.graphics.Rect import android.graphics.Rect
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.Dimension import androidx.annotation.Dimension
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import androidx.core.content.res.use import androidx.core.content.res.use
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import kotlin.math.max import kotlin.math.max
@ -33,9 +32,8 @@ import kotlin.math.max
class GraphView @JvmOverloads constructor( class GraphView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0, defStyleAttr: Int = 0
defStyleRes: Int = 0 ) : View(context, attrs, defStyleAttr) {
) : AppCompatImageView(context, attrs, defStyleAttr) {
@get:ColorInt @get:ColorInt
@ColorInt @ColorInt
var primaryLineColor = 0 var primaryLineColor = 0
@ -55,7 +53,7 @@ class GraphView @JvmOverloads constructor(
@ColorInt @ColorInt
var metaColor = 0 var metaColor = 0
var proportionalTrending = false private var proportionalTrending = false
private lateinit var primaryLinePaint: Paint private lateinit var primaryLinePaint: Paint
private lateinit var secondaryLinePaint: Paint private lateinit var secondaryLinePaint: Paint
@ -129,16 +127,14 @@ class GraphView @JvmOverloads constructor(
private fun initFromXML(attr: AttributeSet?) { private fun initFromXML(attr: AttributeSet?) {
context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a -> context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a ->
primaryLineColor = ContextCompat.getColor( primaryLineColor = context.getColor(
context,
a.getResourceId( a.getResourceId(
R.styleable.GraphView_primaryLineColor, R.styleable.GraphView_primaryLineColor,
R.color.tusky_blue R.color.tusky_blue
) )
) )
secondaryLineColor = ContextCompat.getColor( secondaryLineColor = context.getColor(
context,
a.getResourceId( a.getResourceId(
R.styleable.GraphView_secondaryLineColor, R.styleable.GraphView_secondaryLineColor,
R.color.tusky_red R.color.tusky_red
@ -150,16 +146,14 @@ class GraphView @JvmOverloads constructor(
R.dimen.graph_line_thickness R.dimen.graph_line_thickness
).toFloat() ).toFloat()
graphColor = ContextCompat.getColor( graphColor = context.getColor(
context,
a.getResourceId( a.getResourceId(
R.styleable.GraphView_graphColor, R.styleable.GraphView_graphColor,
R.color.colorBackground R.color.colorBackground
) )
) )
metaColor = ContextCompat.getColor( metaColor = context.getColor(
context,
a.getResourceId( a.getResourceId(
R.styleable.GraphView_metaColor, R.styleable.GraphView_metaColor,
R.color.dividerColor R.color.dividerColor

View File

@ -0,0 +1,185 @@
package com.keylesspalace.tusky.view
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
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 java.lang.Float.max
import java.lang.Float.min
/**
* Slider preference
*
* Similar to [androidx.preference.SeekBarPreference], but better because:
*
* - Uses a [Slider] instead of a [android.widget.SeekBar]. Slider supports float values, and step sizes
* other than 1.
* - Displays the currently selected value in the Preference's summary, for consistency
* with platform norms.
* - Icon buttons can be displayed at the start/end of the slider. Pressing them will
* increment/decrement the slider by `stepSize`.
* - User can supply a custom formatter to format the summary value
*/
class SliderPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle,
defStyleRes: Int = 0
) : Preference(context, attrs, defStyleAttr, defStyleRes),
Slider.OnChangeListener,
Slider.OnSliderTouchListener {
/** Backing property for `value` */
private var _value = 0F
/**
* @see Slider.getValue
* @see Slider.setValue
*/
var value: Float = defaultValue
get() = _value
set(v) {
val clamped = max(max(v, valueFrom), min(v, valueTo))
if (clamped == field) return
_value = clamped
persistFloat(v)
notifyChanged()
}
/** @see Slider.setValueFrom */
var valueFrom: Float
/** @see Slider.setValueTo */
var valueTo: Float
/** @see Slider.setStepSize */
var stepSize: Float
/**
* Format string to be applied to values before setting the summary. For more control set
* [SliderPreference.formatter]
*/
var format: String = defaultFormat
/**
* Function that will be used to format the summary. The default formatter formats using the
* value of the [SliderPreference.format] property.
*/
var formatter: (Float) -> String = { format.format(it) }
/**
* Optional icon to show in a button at the start of the slide. If non-null the button is
* shown. Clicking the button decrements the value by one step.
*/
var decrementIcon: Drawable? = null
/**
* Optional icon to show in a button at the end of the slider. If non-null the button is
* shown. Clicking the button increments the value by one step.
*/
var incrementIcon: Drawable? = null
/** View binding */
private lateinit var binding: PrefSliderBinding
init {
// Using `widgetLayoutResource` here would be incorrect, as that tries to put the entire
// preference layout to the right of the title and summary.
layoutResource = R.layout.pref_slider
val a = context.obtainStyledAttributes(attrs, R.styleable.SliderPreference, defStyleAttr, defStyleRes)
value = a.getFloat(R.styleable.SliderPreference_android_value, defaultValue)
valueFrom = a.getFloat(R.styleable.SliderPreference_android_valueFrom, defaultValueFrom)
valueTo = a.getFloat(R.styleable.SliderPreference_android_valueTo, defaultValueTo)
stepSize = a.getFloat(R.styleable.SliderPreference_android_stepSize, defaultStepSize)
format = a.getString(R.styleable.SliderPreference_format) ?: defaultFormat
val decrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconStart, -1)
if (decrementIconResource != -1) {
decrementIcon = AppCompatResources.getDrawable(context, decrementIconResource)
}
val incrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconEnd, -1)
if (incrementIconResource != -1) {
incrementIcon = AppCompatResources.getDrawable(context, incrementIconResource)
}
a.recycle()
}
override fun onGetDefaultValue(a: TypedArray, i: Int): Any {
return a.getFloat(i, defaultValue)
}
override fun onSetInitialValue(defaultValue: Any?) {
value = getPersistedFloat((defaultValue ?: Companion.defaultValue) as Float)
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
binding = PrefSliderBinding.bind(holder.itemView)
binding.root.isClickable = false
binding.slider.addOnChangeListener(this)
binding.slider.addOnSliderTouchListener(this)
binding.slider.value = value // sliderValue
binding.slider.valueTo = valueTo
binding.slider.valueFrom = valueFrom
binding.slider.stepSize = stepSize
// Disable the label, the value is shown in the preference summary
binding.slider.labelBehavior = LABEL_GONE
binding.slider.isEnabled = isEnabled
binding.summary.visibility = VISIBLE
binding.summary.text = formatter(value)
decrementIcon?.let { icon ->
binding.decrement.icon = icon
binding.decrement.visibility = VISIBLE
binding.decrement.setOnClickListener {
value -= stepSize
}
}
incrementIcon?.let { icon ->
binding.increment.icon = icon
binding.increment.visibility = VISIBLE
binding.increment.setOnClickListener {
value += stepSize
}
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
if (!fromUser) return
binding.summary.text = formatter(value)
}
override fun onStartTrackingTouch(slider: Slider) {
// Deliberately empty
}
override fun onStopTrackingTouch(slider: Slider) {
value = slider.value
}
companion object {
private const val TAG = "SliderPreference"
private const val defaultValueFrom = 0F
private const val defaultValueTo = 1F
private const val defaultValue = 0.5F
private const val defaultStepSize = 0.1F
private const val defaultFormat = "%3.1f"
}
}

View File

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.viewdata package com.keylesspalace.tusky.viewdata
import com.keylesspalace.tusky.entity.TrendingTag
import com.keylesspalace.tusky.entity.end
import com.keylesspalace.tusky.entity.start
import java.util.Date import java.util.Date
sealed class TrendingViewData { sealed class TrendingViewData {
@ -31,18 +28,13 @@ sealed class TrendingViewData {
get() = start.toString() + end.toString() get() = start.toString() + end.toString()
} }
fun asHeaderOrNull(): Header? {
val tag = (this as? Tag)?.tag
?: return null
return Header(tag.start(), tag.end())
}
data class Tag( data class Tag(
val tag: TrendingTag val name: String,
val usage: List<Long>,
val accounts: List<Long>,
val maxTrendingValue: Long
) : TrendingViewData() { ) : TrendingViewData() {
override val id: String override val id: String
get() = tag.name get() = name
} }
fun asTagOrNull() = this as? Tag
} }

View File

@ -0,0 +1,53 @@
/*
* 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.worker
import android.app.Notification
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationFetcher
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_FETCH_NOTIFICATION
import javax.inject.Inject
/** Fetch and show new notifications. */
class NotificationWorker(
appContext: Context,
params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher
) : CoroutineWorker(appContext, params) {
val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_notification_worker)
override suspend fun doWork(): Result {
notificationsFetcher.fetchAndShow()
return Result.success()
}
override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_FETCH_NOTIFICATION, notification)
class Factory @Inject constructor(
private val notificationsFetcher: NotificationFetcher
) : ChildWorkerFactory {
override fun createWorker(appContext: Context, params: WorkerParameters): CoroutineWorker {
return NotificationWorker(appContext, params, notificationsFetcher)
}
}
}

View File

@ -0,0 +1,67 @@
/*
* 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.worker
import android.app.Notification
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import javax.inject.Inject
/** Prune the database cache of old statuses. */
class PruneCacheWorker(
appContext: Context,
workerParams: WorkerParameters,
private val appDatabase: AppDatabase,
private val accountManager: AccountManager
) : CoroutineWorker(appContext, workerParams) {
val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_prune_cache)
override suspend fun doWork(): Result {
for (account in accountManager.accounts) {
Log.d(TAG, "Pruning database using account ID: ${account.id}")
appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE)
}
return Result.success()
}
override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification)
companion object {
private const val TAG = "PruneCacheWorker"
private const val MAX_STATUSES_IN_CACHE = 1000
const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic"
}
class Factory @Inject constructor(
private val appDatabase: AppDatabase,
private val accountManager: AccountManager
) : ChildWorkerFactory {
override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker {
return PruneCacheWorker(appContext, params, appDatabase, accountManager)
}
}
}

View File

@ -0,0 +1,71 @@
/*
* 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.worker
import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* Workers implement this and are added to the map in [com.keylesspalace.tusky.di.WorkerModule]
* so they can be created by [WorkerFactory.createWorker].
*/
interface ChildWorkerFactory {
/** Create a new instance of the given worker. */
fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker
}
/**
* Creates workers, delegating to each worker's [ChildWorkerFactory.createWorker] to do the
* creation.
*
* @see [com.keylesspalace.tusky.worker.NotificationWorker]
*/
@Singleton
class WorkerFactory @Inject constructor(
private val workerFactories: Map<Class<out ListenableWorker>, @JvmSuppressWildcards Provider<ChildWorkerFactory>>
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
val key = try {
Class.forName(workerClassName)
} catch (e: ClassNotFoundException) {
// Class might be missing if it was renamed / moved to a different package, as
// periodic work requests from before the rename might still exist. Catch and
// return null, which should stop future requests.
Log.d(TAG, "Invalid class: $workerClassName", e)
null
}
workerFactories[key]?.let {
return it.get().createWorker(appContext, workerParameters)
}
return null
}
companion object {
private const val TAG = "WorkerFactory"
}
}

View File

@ -80,14 +80,13 @@
android:id="@+id/tag" android:id="@+id/tag"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="none" android:ellipsize="end"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:singleLine="true" android:singleLine="true"
android:textAlignment="textStart" android:textAlignment="textStart"
android:textAppearance="?android:attr/textAppearanceListItem" android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textStyle="normal" android:textStyle="normal"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="#itishashtagtuesdayitishashtagtuesday" /> tools:text="#itishashtagtuesdayitishashtagtuesday" />

View File

@ -443,8 +443,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
app:tabGravity="center" app:tabGravity="center"
app:tabMode="scrollable" app:tabMode="scrollable" />
app:tabTextAppearance="@style/TuskyTabAppearance" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View File

@ -144,14 +144,30 @@
android:minHeight="48dp" android:minHeight="48dp"
android:text="@string/pref_title_account_filter_keywords" /> android:text="@string/pref_title_account_filter_keywords" />
<Button <LinearLayout
android:id="@+id/filter_save_button" android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_gravity="end"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="6dp" android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:layout_marginBottom="16dp" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:text="@string/action_save" /> android:gravity="end"
style="?android:attr/buttonBarStyle">
<Button
android:id="@+id/filter_delete_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_delete"
style="?android:attr/buttonBarButtonStyle" />
<Button
android:id="@+id/filter_save_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/action_save"
style="?android:attr/buttonBarButtonStyle" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -25,6 +25,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:visibility="gone"
/> />
<ProgressBar <ProgressBar
@ -33,6 +34,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:visibility="gone"
/> />
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton

View File

@ -28,8 +28,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:tabGravity="fill" app:tabGravity="fill"
app:tabMaxWidth="0dp" app:tabMaxWidth="0dp"
app:tabMode="fixed" app:tabMode="fixed" />
app:tabTextAppearance="@style/TuskyTabAppearance" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View File

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/status_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:contentDescription="@string/action_view_profile"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<ImageView
android:id="@+id/status_avatar_inset"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/status_avatar"
app:layout_constraintEnd_toEndOf="@id/status_avatar"
tools:src="#000"
tools:visibility="visible" />
<TextView
android:id="@+id/status_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toEndOf="@id/status_avatar"
app:layout_constraintTop_toTopOf="@+id/status_avatar"
tools:text="Ente r the void you foooooo"
tools:ignore="SelectableText"/>
<TextView
android:id="@+id/status_username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="@+id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_display_name"
tools:text="\@Entenhausen@birbsarecooooooooooool.site"
tools:ignore="SelectableText" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status_avatar"
app:layout_constraintVertical_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground"
android:scrollbars="vertical" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/initialProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"
android:layout_gravity="center"
android:contentDescription="@string/a11y_label_loading_thread" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/statusView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" >
</com.keylesspalace.tusky.view.BackgroundMessageView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -37,8 +37,8 @@
<com.keylesspalace.tusky.view.BackgroundMessageView <com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/statusView" android:id="@+id/statusView"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_gravity="center" android:layout_gravity="center"
android:visibility="gone" /> android:visibility="gone" />
</FrameLayout> </FrameLayout>

View File

@ -1,41 +1,36 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="72dp" android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingLeft="16dp" android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingRight="16dp" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
> >
<TextView
android:id="@+id/followed_tag"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
tools:text="hashtag" />
<ImageButton <ImageButton
android:id="@+id/followed_tag_unfollow" android:id="@+id/followed_tag_unfollow"
style="@style/TuskyImageButton" style="@style/TuskyImageButton"
android:layout_width="32dp" android:layout_width="32dp"
android:layout_height="32dp" android:layout_height="32dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_unfollow" android:contentDescription="@string/action_unfollow"
android:padding="4dp" android:padding="4dp"
app:srcCompat="@drawable/ic_person_remove_24dp" app:srcCompat="@drawable/ic_person_remove_24dp"
/> />
<TextView </LinearLayout>
android:id="@+id/followed_tag"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:gravity="center_vertical"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
tools:text="#hashtag" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,7 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Copied from android.R.layout.simple_list_item_1, because view binding does not work with
android.R.layout.* -->
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/text1"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="16dp" android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:gravity="center_vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:background="?attr/selectableItemBackground" android:background="?attr/selectableItemBackground"
android:textSize="?attr/status_text_medium" /> tools:ignore="SelectableText" />

View File

@ -4,30 +4,29 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="?attr/selectableItemBackground"
tools:ignore="Overdraw">
<TextView <TextView
android:id="@+id/list_name_textview" android:id="@+id/list_name_textview"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="60dp" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:background="?selectableItemBackground"
android:drawablePadding="8dp" android:drawablePadding="8dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingLeft="16dp" android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:paddingRight="16dp"
android:textSize="?attr/status_text_medium"
tools:text="Example list" /> tools:text="Example list" />
<ImageButton <ImageButton
android:id="@+id/editListButton" android:id="@+id/editListButton"
style="@style/TuskyImageButton" style="@style/TuskyImageButton"
android:layout_width="36dp" android:layout_width="32dp"
android:layout_height="wrap_content" android:layout_height="32dp"
android:layout_margin="8dp"
android:background="?selectableItemBackgroundBorderless" android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/action_more" android:contentDescription="@string/action_more"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:src="@drawable/ic_more_horiz_24dp" /> android:src="@drawable/ic_more_horiz_24dp" />
</LinearLayout> </LinearLayout>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawablePadding="8dp"
android:textColor="@color/textColorSecondary"
android:textSize="?attr/status_text_medium" />

View File

@ -6,33 +6,21 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingBottom="6dp"> android:paddingLeft="16dp"
android:paddingRight="16dp"
<ImageView android:paddingBottom="8dp">
android:id="@+id/status_edit_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="14dp"
android:layout_marginTop="14dp"
android:contentDescription="@string/action_view_profile"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<TextView <TextView
android:id="@+id/status_edit_info" android:id="@+id/status_edit_info"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp" android:layout_marginTop="8dp"
android:layout_marginTop="10dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="2" android:maxLines="2"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/status_edit_avatar"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="\@Tusky edited 18th December 2022" /> tools:text="\@Tusky edited 18th December 2022" />
@ -40,34 +28,27 @@
android:id="@+id/status_edit_content_warning_description" android:id="@+id/status_edit_content_warning_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp" android:layout_marginTop="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="8dp"
android:hyphenationFrequency="full" android:hyphenationFrequency="full"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1" android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
android:visibility="gone" android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="@+id/status_edit_info"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_avatar" app:layout_constraintTop_toBottomOf="@+id/status_edit_info"
tools:text="content warning which is very long and it doesn't fit" tools:text="content warning which is very long and it doesn't fit"
tools:visibility="visible" /> tools:visibility="visible" />
<View <View
android:id="@+id/status_edit_content_warning_separator" android:id="@+id/status_edit_content_warning_separator"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="4dp"
android:background="?android:textColorPrimary" android:background="?android:textColorPrimary"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:paddingLeft="16dp" app:layout_constraintEnd_toEndOf="@+id/status_edit_content_warning_description"
android:paddingRight="16dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_content_warning_description" /> app:layout_constraintTop_toBottomOf="@id/status_edit_content_warning_description" />
@ -75,62 +56,52 @@
android:id="@+id/status_edit_content" android:id="@+id/status_edit_content"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp" android:layout_marginTop="4dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="8dp"
android:focusable="true" android:focusable="true"
android:hyphenationFrequency="full" android:hyphenationFrequency="full"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1" android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="@+id/status_edit_info"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_content_warning_separator" app:layout_constraintTop_toBottomOf="@id/status_edit_content_warning_separator"
tools:text="This is an edited status" /> tools:text="This is an edited status" />
<com.keylesspalace.tusky.view.MediaPreviewLayout <com.keylesspalace.tusky.view.MediaPreviewLayout
android:id="@+id/status_edit_media_preview" android:id="@+id/status_edit_media_preview"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp" android:layout_marginTop="4dp"
android:layout_marginTop="@dimen/status_media_preview_margin_top"
android:layout_marginEnd="14dp"
android:layout_marginBottom="6dp"
android:background="@drawable/media_preview_outline" android:background="@drawable/media_preview_outline"
android:importantForAccessibility="noHideDescendants" android:importantForAccessibility="noHideDescendants"
app:layout_constraintEnd_toEndOf="@id/status_edit_info"
app:layout_constraintStart_toStartOf="@+id/status_edit_content_warning_description"
app:layout_constraintTop_toBottomOf="@id/status_edit_content" /> app:layout_constraintTop_toBottomOf="@id/status_edit_content" />
<TextView <TextView
android:id="@+id/status_edit_media_sensitivity" android:id="@+id/status_edit_media_sensitivity"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="6dp"
android:text="@string/post_sensitive_media_title" android:text="@string/post_sensitive_media_title"
android:textColor="?android:attr/textColorTertiary" android:textColor="?android:attr/textColorTertiary"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="@+id/status_edit_info"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_media_preview" /> app:layout_constraintTop_toBottomOf="@id/status_edit_media_preview" />
<!-- hidden because as of Mastodon 4.0.2 we don't get this info via the api -->
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/status_edit_poll_options" android:id="@+id/status_edit_poll_options"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_marginEnd="14dp"
android:layout_marginBottom="4dp"
android:nestedScrollingEnabled="false" android:nestedScrollingEnabled="false"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="@+id/status_edit_info"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/status_edit_media_sensitivity" /> app:layout_constraintTop_toBottomOf="@id/status_edit_media_sensitivity" />
<!-- hidden because as of Mastodon 4.0.2 we don't get this info via the api -->
<TextView <TextView
android:id="@+id/status_edit_poll_description" android:id="@+id/status_edit_poll_description"
android:layout_width="0dp" android:layout_width="0dp"

View File

@ -57,7 +57,6 @@
android:paddingStart="0dp" android:paddingStart="0dp"
android:paddingEnd="@dimen/status_display_name_padding_end" android:paddingEnd="@dimen/status_display_name_padding_end"
android:paddingBottom="4dp" android:paddingBottom="4dp"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small"
android:textColor="?android:textColorTertiary" android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_medium"
android:textStyle="normal|bold" android:textStyle="normal|bold"

View File

@ -6,9 +6,10 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?android:colorBackground" android:background="?android:colorBackground"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingEnd="16dp"> android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:minHeight="?android:attr/listPreferredItemHeightSmall">
<ImageView <ImageView
android:id="@+id/imageView" android:id="@+id/imageView"
@ -19,7 +20,8 @@
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:src="@drawable/ic_drag_indicator_24dp" android:src="@drawable/ic_drag_indicator_24dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/textView"/>
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
@ -30,8 +32,7 @@
android:drawablePadding="12dp" android:drawablePadding="12dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:textColor="?android:attr/textColorSecondary" android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:textSize="?attr/status_text_large"
app:drawableTint="?android:attr/textColorSecondary" app:drawableTint="?android:attr/textColorSecondary"
app:layout_constraintBottom_toTopOf="@id/chipGroup" app:layout_constraintBottom_toTopOf="@id/chipGroup"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

View File

@ -11,8 +11,7 @@
android:lines="1" android:lines="1"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingEnd="8dp" android:paddingEnd="8dp"
android:textColor="?android:attr/textColorSecondary" android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:textSize="?attr/status_text_large"
app:drawableStartCompat="@drawable/ic_home_24dp" app:drawableStartCompat="@drawable/ic_home_24dp"
app:drawableTint="?android:attr/textColorSecondary" /> app:drawableTint="?android:attr/textColorSecondary" />

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