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:
commit
e5d9b56dd0
|
@ -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
|
47
CHANGELOG.md
47
CHANGELOG.md
|
@ -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
|
||||||
|
|
|
@ -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
|
@ -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>
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,13 +314,14 @@ 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)
|
||||||
showProgressBarJob.start()
|
showProgressBarJob.start()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -393,17 +393,10 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,17 +1211,20 @@ 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)
|
|
||||||
ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning)
|
|
||||||
ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog()
|
|
||||||
ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog()
|
|
||||||
}.show()
|
|
||||||
} else {
|
|
||||||
viewModel.stopUploads()
|
viewModel.stopUploads()
|
||||||
finishWithoutSlideOutAnimation()
|
finishWithoutSlideOutAnimation()
|
||||||
}
|
}
|
||||||
|
ConfirmationKind.SAVE_OR_DISCARD ->
|
||||||
|
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||||
|
ConfirmationKind.UPDATE_OR_DISCARD ->
|
||||||
|
getUpdateDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||||
|
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()
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,17 +145,10 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,38 +71,48 @@ 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 {
|
||||||
|
if (refresh) {
|
||||||
|
_uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING)
|
||||||
|
} else {
|
||||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
|
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
val deferredFilters = async { mastodonApi.getFilters() }
|
val deferredFilters = async { mastodonApi.getFilters() }
|
||||||
val response = mastodonApi.trendingTags()
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val homeFilters = deferredFilters.await().getOrNull()?.filter {
|
mastodonApi.trendingTags().fold(
|
||||||
it.context.contains(Filter.Kind.HOME.kind)
|
{ tagResponse ->
|
||||||
}
|
|
||||||
|
|
||||||
val tags = response.body()!!
|
val firstTag = tagResponse.firstOrNull()
|
||||||
.filter {
|
_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 ->
|
homeFilters?.none { filter ->
|
||||||
filter.keywords.any { keyword -> keyword.keyword.equals(it.name, ignoreCase = true) }
|
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
|
||||||
} ?: false
|
} ?: false
|
||||||
}
|
}
|
||||||
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
|
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
|
||||||
.map { it.toViewData() }
|
.toViewData()
|
||||||
.asReversed()
|
|
||||||
|
|
||||||
_uiState.value = TrendingUiState(tags, LoadingState.LOADED)
|
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
|
||||||
} catch (e: IOException) {
|
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)
|
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
|
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "TrendingViewModel"
|
private const val TAG = "TrendingViewModel"
|
||||||
|
|
|
@ -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 }) {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 -> {
|
||||||
|
binding.statusView.setup(
|
||||||
|
R.drawable.elephant_friend_empty,
|
||||||
|
R.string.error_missing_edits
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.statusView.setup(uiState.throwable) {
|
||||||
viewModel.loadEdits(statusId, force = true)
|
viewModel.loadEdits(statusId, force = true)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,8 +61,18 @@ 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)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// `edits` might have fewer than the minimum number of entries because of
|
||||||
|
// https://github.com/mastodon/mastodon/issues/25398.
|
||||||
|
if (edits.size < 2) {
|
||||||
|
_uiState.value = EditsUiState.Error(MissingEditsException)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// Diff each status' content against the previous version, producing new
|
// Diff each status' content against the previous version, producing new
|
||||||
// content with additional `ins` or `del` elements marking inserted or
|
// content with additional `ins` or `del` elements marking inserted or
|
||||||
// deleted content.
|
// deleted content.
|
||||||
|
@ -98,10 +111,7 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
|
||||||
if (i < sortedEdits.size - 1) {
|
if (i < sortedEdits.size - 1) {
|
||||||
currentContent = previousContent
|
currentContent = previousContent
|
||||||
previousContent = loader.load(
|
previousContent = loader.load(
|
||||||
sortedEdits[i + 1].content.replace(
|
sortedEdits[i + 1].content.replace("<br>", "<br/>")
|
||||||
"<br>",
|
|
||||||
"<br/>"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -113,11 +123,6 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
|
||||||
_uiState.value = EditsUiState.Success(sortedEdits)
|
_uiState.value = EditsUiState.Success(sortedEdits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{ throwable ->
|
|
||||||
_uiState.value = EditsUiState.Error(throwable)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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?
|
||||||
|
|
|
@ -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?,
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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" />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||||
|
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
|
<Button
|
||||||
android:id="@+id/filter_save_button"
|
android:id="@+id/filter_save_button"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_gravity="end"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="6dp"
|
android:layout_marginStart="8dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:text="@string/action_save"
|
||||||
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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" />
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue