Merge remote-tracking branch 'tuskyapp/develop'
This commit is contained in:
commit
9b5f176008
|
@ -7,11 +7,13 @@ indent_style = space
|
|||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{java,kt}]
|
||||
# Disable wildcard imports
|
||||
[*.{java, kt}]
|
||||
ij_kotlin_name_count_to_use_star_import = 999
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 999
|
||||
ij_java_class_count_to_use_import_on_demand = 999
|
||||
# Enable trailing comma
|
||||
ktlint_disabled_rules=trailing-comma-on-call-site,trailing-comma-on-declaration-site
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
#
|
||||
# Copyright 2023 Tusky Contributors
|
||||
#
|
||||
# This file is a part of Tusky.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
# GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
# the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
# Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
# see <http://www.gnu.org/licenses>.
|
||||
#
|
||||
|
||||
# CI build workers are ephemeral, so don't benefit from the Gradle daemon
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=2
|
||||
|
||||
kotlin.incremental=false
|
||||
kotlin.compiler.execution.strategy=in-process
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- run: |
|
||||
chmod +x ./gradlew
|
||||
|
@ -35,7 +35,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- run: |
|
||||
chmod +x ./gradlew
|
||||
|
|
|
@ -17,12 +17,15 @@ jobs:
|
|||
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '11'
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Gradle Wrapper Validation
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- name: Gradle Build Action
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
# Build the app on each push to `develop`, populating the build cache to speed
|
||||
# up CI on PRs.
|
||||
|
||||
name: Populate build cache
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: app:buildGreenDebug
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Gradle Wrapper Validation
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
||||
|
||||
- name: Run app:buildGreenDebug
|
||||
run: ./gradlew app:buildGreenDebug
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- run: |
|
||||
chmod +x ./gradlew
|
||||
|
|
|
@ -22,8 +22,9 @@ We try to follow the [Guide to app architecture](https://developer.android.com/t
|
|||
|
||||
### Kotlin
|
||||
Tusky was originally written in Java, but is in the process of migrating to Kotlin. All new code must be written in Kotlin.
|
||||
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
|
||||
You can check the codestyle by running `./gradlew ktlintCheck`.
|
||||
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
|
||||
You can check the codestyle by running `./gradlew ktlintCheck lint`. This will fail if you have any errors, and produces a detailed report which also lists warnings.
|
||||
We intentionally have very few hard linting errors, so that new contributors can focus on what they want to achieve instead of fighting the linter.
|
||||
|
||||
### Text
|
||||
All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages.
|
||||
|
@ -42,12 +43,15 @@ All icons are from the Material iconset, find new icons [here](https://fonts.goo
|
|||
We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information.
|
||||
|
||||
### Supported servers
|
||||
Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features.
|
||||
Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon API, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky, but no special effort is made to support their quirks or additional features.
|
||||
|
||||
### Payment Policy
|
||||
Our payment policy may be viewed [here](https://github.com/tuskyapp/Tusky/blob/develop/doc/PaymentPolicy.md).
|
||||
|
||||
## Troubleshooting / FAQ
|
||||
|
||||
- Tusky should be built with the newest version of Android Studio
|
||||
- Tusky should be built with the newest version of Android Studio.
|
||||
- Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases.
|
||||
|
||||
## Resources
|
||||
- [Mastodon Api documentation](https://docs.joinmastodon.org/api/)
|
||||
- [Mastodon API documentation](https://docs.joinmastodon.org/api/)
|
||||
|
|
|
@ -27,7 +27,7 @@ android {
|
|||
defaultConfig {
|
||||
applicationId APP_ID
|
||||
namespace "com.keylesspalace.tusky"
|
||||
minSdk 23
|
||||
minSdk 24
|
||||
targetSdk 33
|
||||
versionCode 56
|
||||
versionName '4.6.0'
|
||||
|
@ -68,8 +68,7 @@ android {
|
|||
|
||||
lint {
|
||||
lintConfig file("lint.xml")
|
||||
// Regenerate by deleting app/lint-baseline.xml, then run:
|
||||
// ./gradlew lintBlueDebug
|
||||
// Regenerate by running `./gradlew app:newLintBaseline`
|
||||
baseline = file("lint-baseline.xml")
|
||||
}
|
||||
|
||||
|
@ -108,12 +107,6 @@ android {
|
|||
includeInApk false
|
||||
includeInBundle false
|
||||
}
|
||||
// Can remove this once https://issuetracker.google.com/issues/260059413 is fixed.
|
||||
// https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
applicationVariants.configureEach { variant ->
|
||||
variant.outputs.configureEach {
|
||||
outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" +
|
||||
|
@ -166,7 +159,7 @@ dependencies {
|
|||
|
||||
implementation libs.sparkbutton
|
||||
|
||||
implementation libs.photoview
|
||||
implementation libs.touchimageview
|
||||
|
||||
implementation libs.bundles.material.drawer
|
||||
implementation libs.material.typeface
|
||||
|
@ -197,3 +190,20 @@ dependencies {
|
|||
implementation libs.accelfeaster
|
||||
implementation libs.jsoup
|
||||
}
|
||||
|
||||
// Work around warnings of:
|
||||
// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context()
|
||||
// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask).configureEach {
|
||||
kaptProcessJvmArgs.addAll([
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
|
||||
"--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"])
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
45
app/lint.xml
45
app/lint.xml
|
@ -29,20 +29,47 @@
|
|||
|
||||
Disable these for the time being. -->
|
||||
<issue id="UnusedIds" severity="ignore" />
|
||||
<issue id="UnusedResources" severity="ignore" />
|
||||
|
||||
<!-- Logs are stripped in release builds. -->
|
||||
<issue id="LogConditional" severity="ignore" />
|
||||
|
||||
<!-- Ensure we are warned about errors in the baseline -->
|
||||
<issue id="LintBaseline" severity="warning" />
|
||||
<!-- Newer dependencies are handled by Renovate, and don't need a warning -->
|
||||
<issue id="GradleDependency" severity="ignore" />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- Typographical punctuation is not something we care about at the moment -->
|
||||
<issue id="TypographyQuotes" severity="ignore" />
|
||||
<issue id="TypographyDashes" severity="ignore" />
|
||||
<issue id="TypographyEllipsis" severity="ignore" />
|
||||
|
||||
<!-- Set OldTargetApi back to warning -->
|
||||
<issue id="OldTargetApi" severity="warning" />
|
||||
<!-- Translations come from external parties -->
|
||||
<issue id="MissingQuantity" severity="ignore" />
|
||||
<issue id="ImpliedQuantity" severity="ignore" />
|
||||
<!-- Most alleged typos are in translations -->
|
||||
<issue id="Typos" severity="ignore" />
|
||||
|
||||
<!-- Mark all other lint issues as errors -->
|
||||
<issue id="all" severity="error" />
|
||||
<!-- Basically all of our vectors are external -->
|
||||
<issue id="VectorPath" severity="ignore" />
|
||||
<issue id="Overdraw" severity="ignore" />
|
||||
|
||||
<!-- Irrelevant api version warnings -->
|
||||
<issue id="OldTargetApi" severity="ignore" />
|
||||
<issue id="UnusedAttribute" severity="ignore" />
|
||||
|
||||
<!-- We do not *want* all the text in the app to be selectable -->
|
||||
<issue id="SelectableText" severity="ignore" />
|
||||
|
||||
<!-- This is heavily used by the viewbinding helper -->
|
||||
<issue id="SyntheticAccessor" severity="ignore" />
|
||||
|
||||
<!-- Things we would actually question in a code review -->
|
||||
<issue id="MissingPermission" severity="error" />
|
||||
<issue id="InvalidPackage" severity="error" />
|
||||
<issue id="UseCompatLoadingForDrawables" severity="error" />
|
||||
<issue id="UseCompatTextViewDrawableXml" severity="error" />
|
||||
<issue id="Recycle" severity="error" />
|
||||
<issue id="KeyboardInaccessibleWidget" severity="error" />
|
||||
|
||||
<!-- Mark all other lint issues as warnings -->
|
||||
<issue id="all" severity="warning" />
|
||||
</lint>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -131,7 +131,7 @@
|
|||
android:theme="@style/Base.Theme.AppCompat" />
|
||||
<activity
|
||||
android:name=".components.search.SearchActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
|
@ -149,7 +149,7 @@
|
|||
<activity
|
||||
android:name=".components.report.ReportActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||
<activity android:name=".components.instancemute.InstanceListActivity" />
|
||||
<activity android:name=".components.domainblocks.DomainBlocksActivity" />
|
||||
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
|
||||
<activity android:name=".components.announcements.AnnouncementsActivity" />
|
||||
<activity android:name=".components.drafts.DraftsActivity" />
|
||||
|
@ -189,8 +189,7 @@
|
|||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/tusky_compose_post_quicksetting_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true"
|
||||
tools:targetApi="24">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
|
@ -8,14 +12,22 @@ import android.text.method.LinkMovementMethod
|
|||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.databinding.ActivityAboutBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.util.NoUnderlineURLSpan
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import net.accelf.yuito.AccessTokenLoginActivity
|
||||
|
||||
class AboutActivity : BottomSheetActivity(), Injectable {
|
||||
@Inject
|
||||
lateinit var instanceInfoRepository: InstanceInfoRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -37,6 +49,28 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
|
||||
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
|
||||
|
||||
binding.deviceInfo.text = getString(
|
||||
R.string.about_device_info,
|
||||
Build.MANUFACTURER,
|
||||
Build.MODEL,
|
||||
Build.VERSION.RELEASE,
|
||||
Build.VERSION.SDK_INT
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
accountManager.activeAccount?.let { account ->
|
||||
val instanceInfo = instanceInfoRepository.getInstanceInfo()
|
||||
binding.accountInfo.text = getString(
|
||||
R.string.about_account_info,
|
||||
account.username,
|
||||
account.domain,
|
||||
instanceInfo.version
|
||||
)
|
||||
binding.accountInfoTitle.show()
|
||||
binding.accountInfo.show()
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.CUSTOM_INSTANCE.isBlank()) {
|
||||
binding.aboutPoweredByTusky.hide()
|
||||
}
|
||||
|
@ -53,6 +87,16 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
binding.aboutLicensesButton.setOnClickListener {
|
||||
startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java))
|
||||
}
|
||||
|
||||
binding.copyDeviceInfo.setOnClickListener {
|
||||
val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}"
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Tusky version information", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEasterEggExecute() {
|
||||
|
|
|
@ -47,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager;
|
|||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
|
||||
import com.keylesspalace.tusky.interfaces.PermissionRequester;
|
||||
import com.keylesspalace.tusky.settings.AppTheme;
|
||||
import com.keylesspalace.tusky.settings.PrefKeys;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
|
@ -56,10 +57,13 @@ import java.util.List;
|
|||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME;
|
||||
|
||||
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
|
||||
private static final String TAG = "BaseActivity";
|
||||
|
||||
@Inject
|
||||
@NonNull
|
||||
public AccountManager accountManager;
|
||||
|
||||
private static final int REQUESTER_NONE = Integer.MAX_VALUE;
|
||||
|
@ -74,9 +78,9 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
/* There isn't presently a way to globally change the theme of a whole application at
|
||||
* runtime, just individual activities. So, each activity has to set its theme before any
|
||||
* views are created. */
|
||||
String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT);
|
||||
String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue());
|
||||
Log.d("activeTheme", theme);
|
||||
if (theme.equals("black")) {
|
||||
if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) {
|
||||
setTheme(R.style.TuskyBlackTheme);
|
||||
}
|
||||
|
||||
|
@ -87,7 +91,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
|
||||
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
|
||||
|
||||
int style = textStyle(preferences.getString("statusTextSize", "medium"));
|
||||
int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"));
|
||||
getTheme().applyStyle(style, true);
|
||||
|
||||
if(requiresLogin()) {
|
||||
|
@ -162,13 +166,13 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
return style;
|
||||
}
|
||||
|
||||
public void startActivityWithSlideInAnimation(Intent intent) {
|
||||
public void startActivityWithSlideInAnimation(@NonNull Intent intent) {
|
||||
super.startActivity(intent);
|
||||
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
return true;
|
||||
|
@ -196,7 +200,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
}
|
||||
|
||||
protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) {
|
||||
protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) {
|
||||
if (anyView != null) {
|
||||
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);
|
||||
bar.setAction(actionId, listener);
|
||||
|
@ -204,7 +208,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
}
|
||||
|
||||
public void showAccountChooserDialog(CharSequence dialogTitle, boolean showActiveAccount, AccountSelectionListener listener) {
|
||||
public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) {
|
||||
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
|
||||
AccountEntity activeAccount = accountManager.getActiveAccount();
|
||||
|
||||
|
@ -256,9 +260,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
|
||||
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
|
||||
accountManager.setActiveAccount(account.getId());
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
intent.putExtra(MainActivity.REDIRECT_URL, url);
|
||||
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
|
||||
|
||||
startActivity(intent);
|
||||
finishWithoutSlideOutAnimation();
|
||||
}
|
||||
|
@ -272,7 +275,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
}
|
||||
|
||||
public void requestPermissions(String[] permissions, PermissionRequester requester) {
|
||||
public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) {
|
||||
ArrayList<String> permissionsToRequest = new ArrayList<>();
|
||||
for(String permission: permissions) {
|
||||
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.lifecycle.Lifecycle
|
|||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
|
@ -94,7 +95,7 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
viewThread(statuses[0].id, statuses[0].url)
|
||||
return@subscribe
|
||||
}
|
||||
accounts.firstOrNull { it.url == url }?.let { account ->
|
||||
accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account ->
|
||||
// Some servers return (unrelated) accounts for url searches (#2804)
|
||||
// Verify that the account's url matches the query
|
||||
viewAccount(account.id)
|
||||
|
|
|
@ -25,7 +25,9 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
@ -46,9 +48,11 @@ import com.keylesspalace.tusky.di.ViewModelFactory
|
|||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.await
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ProfileDataInUi
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
|
@ -96,6 +100,14 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
private val currentProfileData
|
||||
get() = ProfileDataInUi(
|
||||
displayName = binding.displayNameEditText.text.toString(),
|
||||
note = binding.noteEditText.text.toString(),
|
||||
locked = binding.lockedCheckBox.isChecked,
|
||||
fields = accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
@ -200,17 +212,26 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onBackCallback = object : OnBackPressedCallback(enabled = true) {
|
||||
override fun handleOnBackPressed() = checkForUnsavedChanges()
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, onBackCallback)
|
||||
}
|
||||
|
||||
fun checkForUnsavedChanges() {
|
||||
if (viewModel.hasUnsavedChanges(currentProfileData)) {
|
||||
showUnsavedChangesDialog()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!isFinishing) {
|
||||
viewModel.updateProfile(
|
||||
binding.displayNameEditText.text.toString(),
|
||||
binding.noteEditText.text.toString(),
|
||||
binding.lockedCheckBox.isChecked,
|
||||
accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
viewModel.updateProfile(currentProfileData)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -287,14 +308,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
viewModel.save(
|
||||
binding.displayNameEditText.text.toString(),
|
||||
binding.noteEditText.text.toString(),
|
||||
binding.lockedCheckBox.isChecked,
|
||||
accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
}
|
||||
private fun save() = viewModel.save(currentProfileData)
|
||||
|
||||
private fun onSaveFailure(msg: String?) {
|
||||
val errorMsg = msg ?: getString(R.string.error_media_upload_sending)
|
||||
|
@ -306,4 +320,16 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
Log.w("EditProfileActivity", "failed to pick media", throwable)
|
||||
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesDialog() = lifecycleScope.launch {
|
||||
when (launchSaveDialog()) {
|
||||
AlertDialog.BUTTON_POSITIVE -> save()
|
||||
else -> finish()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun launchSaveDialog() = AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.dialog_save_profile_changes_message))
|
||||
.create()
|
||||
.await(R.string.action_save, R.string.action_discard)
|
||||
}
|
||||
|
|
|
@ -23,8 +23,6 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
|
@ -38,10 +36,10 @@ import androidx.recyclerview.widget.DividerItemDecoration
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.databinding.ActivityListsBinding
|
||||
import com.keylesspalace.tusky.databinding.DialogListBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
|
@ -118,7 +116,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
|
||||
Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list)
|
||||
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
|
||||
}
|
||||
}
|
||||
|
@ -126,16 +124,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
}
|
||||
|
||||
private fun showlistNameDialog(list: MastoList?) {
|
||||
val layout = FrameLayout(this)
|
||||
val editText = EditText(this)
|
||||
editText.setHint(R.string.hint_list_name)
|
||||
layout.addView(editText)
|
||||
val margin = Utils.dpToPx(this, 8)
|
||||
(editText.layoutParams as ViewGroup.MarginLayoutParams)
|
||||
.setMargins(margin, margin, margin, 0)
|
||||
|
||||
val binding = DialogListBinding.inflate(layoutInflater).apply {
|
||||
replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal)
|
||||
}
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(layout)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(
|
||||
if (list == null) {
|
||||
R.string.action_create_list
|
||||
|
@ -143,19 +136,33 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
R.string.action_rename_list
|
||||
}
|
||||
) { _, _ ->
|
||||
onPickedDialogName(editText.text, list?.id)
|
||||
onPickedDialogName(
|
||||
binding.nameText.text.toString(),
|
||||
list?.id,
|
||||
binding.exclusiveCheckbox.isChecked,
|
||||
MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy
|
||||
)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
|
||||
binding.nameText.let { editText ->
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
positiveButton.isEnabled = s?.isNotBlank() == true
|
||||
dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true
|
||||
}
|
||||
editText.setText(list?.title)
|
||||
editText.text?.let { editText.setSelection(it.length) }
|
||||
}
|
||||
|
||||
list?.let {
|
||||
if (it.exclusive == null) {
|
||||
binding.exclusiveCheckbox.visible(false)
|
||||
} else {
|
||||
binding.exclusiveCheckbox.isChecked = it.exclusive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showListDeleteDialog(list: MastoList) {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.dialog_delete_list_warning, list.title))
|
||||
|
@ -174,13 +181,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
INITIAL, LOADING -> binding.messageView.hide()
|
||||
ERROR_NETWORK -> {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
|
||||
viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
ERROR_OTHER -> {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
|
||||
viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
|
@ -192,6 +199,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
binding.messageView.showHelp(R.string.help_empty_lists)
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
}
|
||||
|
@ -226,7 +234,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.list_edit -> openListSettings(list)
|
||||
R.id.list_rename -> renameListDialog(list)
|
||||
R.id.list_update -> renameListDialog(list)
|
||||
R.id.list_delete -> showListDeleteDialog(list)
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
|
@ -287,11 +295,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
}
|
||||
}
|
||||
|
||||
private fun onPickedDialogName(name: CharSequence, listId: String?) {
|
||||
private fun onPickedDialogName(name: String, listId: String?, exclusive: Boolean, replyPolicy: String) {
|
||||
if (listId == null) {
|
||||
viewModel.createNewList(name.toString())
|
||||
viewModel.createNewList(name, exclusive, replyPolicy)
|
||||
} else {
|
||||
viewModel.renameList(listId, name.toString())
|
||||
viewModel.updateList(listId, name, exclusive, replyPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ package com.keylesspalace.tusky
|
|||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
|
@ -30,6 +31,7 @@ import android.graphics.drawable.BitmapDrawable
|
|||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.InsetDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
|
@ -38,6 +40,7 @@ import android.view.KeyEvent
|
|||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.ImageView
|
||||
|
@ -50,9 +53,12 @@ import androidx.appcompat.widget.PopupMenu
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.forEach
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
|
@ -69,8 +75,11 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
|
|||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||
import com.keylesspalace.tusky.appstore.CacheUpdater
|
||||
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
||||
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
|
@ -81,7 +90,6 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH
|
|||
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
|
||||
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
|
||||
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
|
||||
|
@ -96,6 +104,8 @@ import com.keylesspalace.tusky.db.DraftsAlert
|
|||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.FabFragment
|
||||
|
@ -115,7 +125,6 @@ import com.keylesspalace.tusky.util.show
|
|||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.updateShortcut
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
|
@ -186,7 +195,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
private lateinit var header: AccountHeaderView
|
||||
|
||||
private var notificationTabPosition = 0
|
||||
private var onTabSelectedListener: OnTabSelectedListener? = null
|
||||
|
||||
private var unreadAnnouncementsCount = 0
|
||||
|
@ -195,8 +203,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
private lateinit var glide: RequestManager
|
||||
|
||||
private var accountLocked: Boolean = false
|
||||
|
||||
// We need to know if the emoji pack has been changed
|
||||
private var selectedEmojiPack: String? = null
|
||||
|
||||
|
@ -206,6 +212,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
/** Adapter for the different timeline tabs */
|
||||
private lateinit var tabAdapter: MainPagerAdapter
|
||||
|
||||
private var directMessageTab: TabLayout.Tab? = null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
@ -213,30 +223,39 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
?: return // will be redirected to LoginActivity by BaseActivity
|
||||
|
||||
var showNotificationTab = false
|
||||
if (intent != null) {
|
||||
|
||||
// check for savedInstanceState in order to not handle intent events more than once
|
||||
if (intent != null && savedInstanceState == null) {
|
||||
val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1)
|
||||
if (notificationId != -1) {
|
||||
// opened from a notification action, cancel the notification
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId)
|
||||
}
|
||||
|
||||
/** there are two possibilities the accountId can be passed to MainActivity:
|
||||
* - from our code as long 'account_id'
|
||||
* - from our code as Long Intent Extra TUSKY_ACCOUNT_ID
|
||||
* - from share shortcuts as String 'android.intent.extra.shortcut.ID'
|
||||
*/
|
||||
var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1)
|
||||
if (accountId == -1L) {
|
||||
var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1)
|
||||
if (tuskyAccountId == -1L) {
|
||||
val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
|
||||
if (accountIdString != null) {
|
||||
accountId = accountIdString.toLong()
|
||||
tuskyAccountId = accountIdString.toLong()
|
||||
}
|
||||
}
|
||||
val accountRequested = accountId != -1L
|
||||
if (accountRequested && accountId != activeAccount.id) {
|
||||
accountManager.setActiveAccount(accountId)
|
||||
val accountRequested = tuskyAccountId != -1L
|
||||
if (accountRequested && tuskyAccountId != activeAccount.id) {
|
||||
accountManager.setActiveAccount(tuskyAccountId)
|
||||
}
|
||||
|
||||
val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
|
||||
|
||||
if (canHandleMimeType(intent.type)) {
|
||||
if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) {
|
||||
// Sharing to Tusky from an external app
|
||||
if (accountRequested) {
|
||||
// The correct account is already active
|
||||
forwardShare(intent)
|
||||
forwardToComposeActivity(intent)
|
||||
} else {
|
||||
// No account was provided, show the chooser
|
||||
showAccountChooserDialog(
|
||||
|
@ -247,10 +266,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
val requestedId = account.id
|
||||
if (requestedId == activeAccount.id) {
|
||||
// The correct account is already active
|
||||
forwardShare(intent)
|
||||
forwardToComposeActivity(intent)
|
||||
} else {
|
||||
// A different account was requested, restart the activity
|
||||
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
|
||||
intent.putExtra(TUSKY_ACCOUNT_ID, requestedId)
|
||||
changeAccount(requestedId, intent)
|
||||
}
|
||||
}
|
||||
|
@ -260,11 +279,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
} else if (openDrafts) {
|
||||
val intent = DraftsActivity.newIntent(this)
|
||||
startActivity(intent)
|
||||
} else if (accountRequested && savedInstanceState == null) {
|
||||
} else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) {
|
||||
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
|
||||
// otherwise show notification tab
|
||||
if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
|
||||
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true)
|
||||
if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
|
||||
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS)
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
} else {
|
||||
showNotificationTab = true
|
||||
|
@ -273,15 +292,27 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.mainToolbar)
|
||||
|
||||
glide = Glide.with(this)
|
||||
|
||||
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
|
||||
binding.composeButton.setOnClickListener(binding.viewQuickToot::onFABClicked)
|
||||
|
||||
// Determine which of the three toolbars should be the supportActionBar (which hosts
|
||||
// the options menu).
|
||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||
binding.mainToolbar.visible(!hideTopToolbar)
|
||||
if (hideTopToolbar) {
|
||||
when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) {
|
||||
"top" -> setSupportActionBar(binding.topNav)
|
||||
"bottom" -> setSupportActionBar(binding.bottomNav)
|
||||
}
|
||||
binding.mainToolbar.hide()
|
||||
// There's not enough space in the top/bottom bars to show the title as well.
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
} else {
|
||||
setSupportActionBar(binding.mainToolbar)
|
||||
binding.mainToolbar.show()
|
||||
}
|
||||
|
||||
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
|
||||
|
||||
|
@ -292,7 +323,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
setupDrawer(
|
||||
savedInstanceState,
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
|
||||
addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS),
|
||||
addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_STATUSES),
|
||||
)
|
||||
|
||||
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
||||
|
@ -321,16 +353,38 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
is MainTabsChangedEvent -> {
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !event.newTabs.hasTab(TRENDING)
|
||||
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS),
|
||||
addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES),
|
||||
)
|
||||
|
||||
setupTabs(false)
|
||||
}
|
||||
|
||||
is AnnouncementReadEvent -> {
|
||||
unreadAnnouncementsCount--
|
||||
updateAnnouncementsBadge()
|
||||
}
|
||||
is NewNotificationsEvent -> {
|
||||
directMessageTab?.let { tab ->
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
val hasDirectMessageNotification =
|
||||
event.notifications.any { it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT }
|
||||
|
||||
if (hasDirectMessageNotification) {
|
||||
showDirectMessageBadge(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is NotificationsLoadingEvent -> {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
showDirectMessageBadge(false)
|
||||
}
|
||||
}
|
||||
is ConversationsLoadingEvent -> {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
showDirectMessageBadge(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.viewQuickToot.handleEvent(event)
|
||||
}
|
||||
|
@ -362,7 +416,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
|
@ -374,6 +431,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
draftsAlert.observeInContext(this, true)
|
||||
}
|
||||
|
||||
private fun showDirectMessageBadge(showBadge: Boolean) {
|
||||
directMessageTab?.let { tab ->
|
||||
tab.badge?.isVisible = showBadge
|
||||
|
||||
// TODO a bit cumbersome (also for resetting)
|
||||
accountManager.activeAccount?.let {
|
||||
if (it.hasDirectMessageBadge != showBadge) {
|
||||
it.hasDirectMessageBadge = showBadge
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.activity_main, menu)
|
||||
menu.findItem(R.id.action_search)?.apply {
|
||||
|
@ -384,6 +455,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
|
||||
// If the main toolbar is hidden then there's no space in the top/bottomNav to show
|
||||
// the menu items as icons, so forceably disable them
|
||||
if (!binding.mainToolbar.isVisible) menu.forEach { it.setShowAsAction(SHOW_AS_ACTION_NEVER) }
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_search -> {
|
||||
|
@ -456,12 +535,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
private fun forwardShare(intent: Intent) {
|
||||
val composeIntent = Intent(this, ComposeActivity::class.java)
|
||||
composeIntent.action = intent.action
|
||||
composeIntent.type = intent.type
|
||||
composeIntent.putExtras(intent)
|
||||
composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
private fun forwardToComposeActivity(intent: Intent) {
|
||||
val composeOptions = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS, ComposeActivity.ComposeOptions::class.java)
|
||||
|
||||
val composeIntent = if (composeOptions != null) {
|
||||
ComposeActivity.startIntent(this, composeOptions)
|
||||
} else {
|
||||
Intent(this, ComposeActivity::class.java).apply {
|
||||
action = intent.action
|
||||
type = intent.type
|
||||
putExtras(intent)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
}
|
||||
startActivity(composeIntent)
|
||||
finish()
|
||||
}
|
||||
|
@ -469,13 +555,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
private fun setupDrawer(
|
||||
savedInstanceState: Bundle?,
|
||||
addSearchButton: Boolean,
|
||||
addTrendingButton: Boolean
|
||||
addTrendingTagsButton: Boolean,
|
||||
addTrendingStatusesButton: Boolean,
|
||||
) {
|
||||
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
||||
|
||||
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
binding.topNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
binding.topNav.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
|
||||
header = AccountHeaderView(this).apply {
|
||||
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
|
||||
|
@ -500,7 +587,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
|
||||
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
|
||||
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
|
||||
|
@ -522,7 +609,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
override fun placeholder(ctx: Context, tag: String?): Drawable {
|
||||
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
|
||||
return ctx.getDrawable(R.drawable.avatar_default)!!
|
||||
return AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!!
|
||||
}
|
||||
|
||||
return super.placeholder(ctx, tag)
|
||||
|
@ -530,12 +617,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
})
|
||||
|
||||
binding.mainDrawer.apply {
|
||||
refreshMainDrawerItems(addSearchButton, addTrendingButton)
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = addSearchButton,
|
||||
addTrendingTagsButton = addTrendingTagsButton,
|
||||
addTrendingStatusesButton = addTrendingStatusesButton,
|
||||
)
|
||||
setSavedInstance(savedInstanceState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
|
||||
private fun refreshMainDrawerItems(
|
||||
addSearchButton: Boolean,
|
||||
addTrendingTagsButton: Boolean,
|
||||
addTrendingStatusesButton: Boolean,
|
||||
) {
|
||||
binding.mainDrawer.apply {
|
||||
itemAdapter.clear()
|
||||
tintStatusBar = true
|
||||
|
@ -569,7 +664,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
nameRes = R.string.action_view_follow_requests
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
|
||||
onClick = {
|
||||
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
|
||||
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS)
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
},
|
||||
|
@ -657,7 +752,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
)
|
||||
}
|
||||
|
||||
if (addTrendingButton) {
|
||||
if (addTrendingTagsButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
5,
|
||||
primaryDrawerItem {
|
||||
|
@ -669,6 +764,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (addTrendingStatusesButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
6,
|
||||
primaryDrawerItem {
|
||||
nameRes = R.string.title_public_trending_statuses
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department
|
||||
onClick = {
|
||||
startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
@ -747,6 +855,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
// Detach any existing mediator before changing tab contents and attaching a new mediator
|
||||
tabLayoutMediator?.detach()
|
||||
|
||||
directMessageTab = null
|
||||
|
||||
tabAdapter.tabs = tabs
|
||||
tabAdapter.notifyItemRangeChanged(0, tabs.size)
|
||||
|
||||
|
@ -763,6 +873,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
if (popups[position] != null) {
|
||||
return@TabLayoutMediator
|
||||
}
|
||||
if (tabs[position].id == DIRECT) {
|
||||
tab.orCreateBadge
|
||||
tab.badge?.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false
|
||||
directMessageTab = tab
|
||||
}
|
||||
|
||||
val popup = PopupMenu(this, tab.view)
|
||||
popup.menuInflater.inflate(R.menu.view_tab_action, popup.menu)
|
||||
|
@ -859,6 +974,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
binding.mainToolbar.title = tab.contentDescription
|
||||
|
||||
refreshComposeButtonState(tabAdapter, tab.position)
|
||||
|
||||
if (tab == directMessageTab) {
|
||||
tab.badge?.isVisible = false
|
||||
|
||||
accountManager.activeAccount?.let {
|
||||
if (it.hasDirectMessageBadge) {
|
||||
it.hasDirectMessageBadge = false
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||
|
@ -870,8 +996,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
activeTabLayout.addOnTabSelectedListener(it)
|
||||
}
|
||||
|
||||
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
|
||||
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
|
||||
supportActionBar?.title = tabs[position].title(this@MainActivity)
|
||||
binding.mainToolbar.setOnClickListener {
|
||||
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
|
||||
}
|
||||
|
@ -998,62 +1123,38 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
disableAllNotifications(this, accountManager)
|
||||
}
|
||||
|
||||
accountLocked = me.locked
|
||||
|
||||
updateProfiles()
|
||||
updateShortcut(this, accountManager.activeAccount!!)
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
|
||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
if (hideTopToolbar) {
|
||||
val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom"
|
||||
|
||||
val avatarView = if (navOnBottom) {
|
||||
binding.bottomNavAvatar.show()
|
||||
binding.bottomNavAvatar
|
||||
val activeToolbar = if (hideTopToolbar) {
|
||||
val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom"
|
||||
if (navOnBottom) {
|
||||
binding.bottomNav
|
||||
} else {
|
||||
binding.topNavAvatar.show()
|
||||
binding.topNavAvatar
|
||||
}
|
||||
|
||||
if (animateAvatars) {
|
||||
Glide.with(this)
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
} else {
|
||||
Glide.with(this)
|
||||
.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
binding.topNav
|
||||
}
|
||||
} else {
|
||||
binding.bottomNavAvatar.hide()
|
||||
binding.topNavAvatar.hide()
|
||||
binding.mainToolbar
|
||||
}
|
||||
|
||||
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
|
||||
|
||||
if (animateAvatars) {
|
||||
glide.asDrawable()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
glide.asDrawable().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
|
||||
.apply {
|
||||
if (showPlaceholder) {
|
||||
placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
if (showPlaceholder) placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1061,37 +1162,25 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
resource: Drawable,
|
||||
transition: Transition<in Drawable>?
|
||||
) {
|
||||
if (resource is Animatable) {
|
||||
resource.start()
|
||||
}
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(resource, navIconSize, navIconSize)
|
||||
if (resource is Animatable) resource.start()
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
glide.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
glide.asBitmap().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)))
|
||||
.apply {
|
||||
if (showPlaceholder) {
|
||||
placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
if (showPlaceholder) placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1099,7 +1188,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
resource: Bitmap,
|
||||
transition: Transition<in Bitmap>?
|
||||
) {
|
||||
binding.mainToolbar.navigationIcon = FixedSizeDrawable(
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(
|
||||
BitmapDrawable(resources, resource),
|
||||
navIconSize,
|
||||
navIconSize
|
||||
|
@ -1107,15 +1196,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAnnouncements() {
|
||||
lifecycleScope.launch {
|
||||
|
@ -1175,8 +1262,75 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
private const val TAG = "MainActivity" // logging tag
|
||||
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
|
||||
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
|
||||
const val REDIRECT_URL = "redirectUrl"
|
||||
const val OPEN_DRAFTS = "draft"
|
||||
private const val REDIRECT_URL = "redirectUrl"
|
||||
private const val OPEN_DRAFTS = "draft"
|
||||
private const val TUSKY_ACCOUNT_ID = "tuskyAccountId"
|
||||
private const val COMPOSE_OPTIONS = "composeOptions"
|
||||
private const val NOTIFICATION_TYPE = "notificationType"
|
||||
private const val NOTIFICATION_TAG = "notificationTag"
|
||||
private const val NOTIFICATION_ID = "notificationId"
|
||||
|
||||
/**
|
||||
* Switches the active account to the provided accountId and then stays on MainActivity
|
||||
*/
|
||||
@JvmStatic
|
||||
fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openNotificationIntent(context: Context, tuskyAccountId: Long, type: Notification.Type): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(NOTIFICATION_TYPE, type.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active account to the accountId and then opens ComposeActivity with the provided options
|
||||
* @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account.
|
||||
* @param notificationId optional id of the notification that should be cancelled when this intent is opened
|
||||
* @param notificationTag optional tag of the notification that should be cancelled when this intent is opened
|
||||
*/
|
||||
@JvmStatic
|
||||
fun composeIntent(
|
||||
context: Context,
|
||||
options: ComposeActivity.ComposeOptions,
|
||||
tuskyAccountId: Long = -1,
|
||||
notificationTag: String? = null,
|
||||
notificationId: Int = -1
|
||||
): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
action = Intent.ACTION_SEND // so it can be opened via shortcuts
|
||||
putExtra(COMPOSE_OPTIONS, options)
|
||||
putExtra(NOTIFICATION_TAG, notificationTag)
|
||||
putExtra(NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* switches the active account to the accountId and then tries to resolve and show the provided url
|
||||
*/
|
||||
@JvmStatic
|
||||
fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(REDIRECT_URL, url)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* switches the active account to the provided accountId and then opens drafts
|
||||
*/
|
||||
fun draftIntent(context: Context, tuskyAccountId: Long): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(OPEN_DRAFTS, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -28,18 +28,20 @@ import at.connyduck.calladapter.networkresult.fold
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.filters.EditFilterActivity
|
||||
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
|
||||
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterV1
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import kotlinx.coroutines.launch
|
||||
import net.accelf.yuito.QuickTootViewModel
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
@ -81,6 +83,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
Kind.FAVOURITES -> getString(R.string.title_favourites)
|
||||
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
|
||||
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
|
||||
Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
|
||||
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
|
||||
}
|
||||
|
||||
|
@ -146,6 +149,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
{
|
||||
followTagItem?.isVisible = false
|
||||
unfollowTagItem?.isVisible = true
|
||||
|
||||
Snackbar.make(binding.root, getString(R.string.following_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
|
@ -166,6 +171,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
{
|
||||
followTagItem?.isVisible = true
|
||||
unfollowTagItem?.isVisible = false
|
||||
|
||||
Snackbar.make(binding.root, getString(R.string.unfollowing_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
|
@ -183,6 +190,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
*/
|
||||
private fun updateMuteTagMenuItems() {
|
||||
val tag = hashtag ?: return
|
||||
val hashedTag = "#$tag"
|
||||
|
||||
muteTagItem?.isVisible = true
|
||||
muteTagItem?.isEnabled = false
|
||||
|
@ -192,18 +200,17 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
mastodonApi.getFilters().fold(
|
||||
{ filters ->
|
||||
mutedFilter = filters.firstOrNull { filter ->
|
||||
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
|
||||
it.keyword == tag
|
||||
}
|
||||
// TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)?
|
||||
filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag
|
||||
}
|
||||
updateTagMuteState(mutedFilter != null)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
mastodonApi.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
mutedFilterV1 = filters.firstOrNull { filter ->
|
||||
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||
}
|
||||
updateTagMuteState(mutedFilterV1 != null)
|
||||
},
|
||||
|
@ -235,6 +242,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
val tag = hashtag ?: return true
|
||||
|
||||
lifecycleScope.launch {
|
||||
var filterCreateSuccess = false
|
||||
val hashedTag = "#$tag"
|
||||
|
||||
mastodonApi.createFilter(
|
||||
title = "#$tag",
|
||||
context = listOf(FilterV1.HOME),
|
||||
|
@ -242,19 +252,22 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
expiresInSeconds = null
|
||||
).fold(
|
||||
{ filter ->
|
||||
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
|
||||
mutedFilter = filter
|
||||
updateTagMuteState(true)
|
||||
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = hashedTag, wholeWord = true).isSuccess) {
|
||||
// must be requested again; otherwise does not contain the keyword (but server does)
|
||||
mutedFilter = mastodonApi.getFilter(filter.id).getOrNull()
|
||||
|
||||
// TODO the preference key here ("home") is not meaningful; should probably be another event if any
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
filterCreateSuccess = true
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "Failed to mute #$tag")
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
mastodonApi.createFilterV1(
|
||||
tag,
|
||||
hashedTag,
|
||||
listOf(FilterV1.HOME),
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
@ -262,8 +275,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
).fold(
|
||||
{ filter ->
|
||||
mutedFilterV1 = filter
|
||||
updateTagMuteState(true)
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
filterCreateSuccess = true
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
|
@ -276,6 +289,24 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (filterCreateSuccess) {
|
||||
updateTagMuteState(true)
|
||||
Snackbar.make(binding.root, getString(R.string.muting_hashtag_success_format, tag), Snackbar.LENGTH_LONG).apply {
|
||||
setAction(R.string.action_view_filter) {
|
||||
val intent = if (mutedFilter != null) {
|
||||
Intent(this@StatusListActivity, EditFilterActivity::class.java).apply {
|
||||
putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter)
|
||||
}
|
||||
} else {
|
||||
Intent(this@StatusListActivity, FiltersActivity::class.java)
|
||||
}
|
||||
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
@ -321,6 +352,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
|
||||
mutedFilterV1 = null
|
||||
mutedFilter = null
|
||||
|
||||
Snackbar.make(binding.root, getString(R.string.unmuting_hashtag_success_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
|
@ -365,5 +398,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
putExtra(EXTRA_KIND, Kind.TAG.name)
|
||||
putExtra(EXTRA_HASHTAG, hashtag)
|
||||
}
|
||||
|
||||
fun newTrendingIntent(context: Context) =
|
||||
Intent(context, StatusListActivity::class.java).apply {
|
||||
putExtra(EXTRA_KIND, Kind.PUBLIC_TRENDING_STATUSES.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import net.accelf.yuito.streaming.StreamType
|
||||
import net.accelf.yuito.streaming.Subscription
|
||||
import java.util.Objects
|
||||
|
@ -35,9 +35,11 @@ const val NOTIFICATIONS = "Notifications"
|
|||
const val LOCAL = "Local"
|
||||
const val FEDERATED = "Federated"
|
||||
const val DIRECT = "Direct"
|
||||
const val TRENDING = "Trending"
|
||||
const val TRENDING_TAGS = "TrendingTags"
|
||||
const val TRENDING_STATUSES = "TrendingStatuses"
|
||||
const val HASHTAG = "Hashtag"
|
||||
const val LIST = "List"
|
||||
const val BOOKMARKS = "Bookmarks"
|
||||
|
||||
const val STREAMING = "STR"
|
||||
|
||||
|
@ -71,9 +73,7 @@ data class TabData(
|
|||
other as TabData
|
||||
|
||||
if (id != other.id) return false
|
||||
if (arguments != other.arguments) return false
|
||||
|
||||
return true
|
||||
return arguments == other.arguments
|
||||
}
|
||||
|
||||
override fun hashCode() = Objects.hash(id, arguments)
|
||||
|
@ -117,11 +117,17 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
|||
icon = R.drawable.ic_reblog_direct_24dp,
|
||||
fragment = { ConversationsFragment.newInstance() }
|
||||
)
|
||||
TRENDING -> TabData(
|
||||
id = TRENDING,
|
||||
TRENDING_TAGS -> TabData(
|
||||
id = TRENDING_TAGS,
|
||||
text = R.string.title_public_trending_hashtags,
|
||||
icon = R.drawable.ic_trending_up_24px,
|
||||
fragment = { TrendingFragment.newInstance() }
|
||||
fragment = { TrendingTagsFragment.newInstance() }
|
||||
)
|
||||
TRENDING_STATUSES -> TabData(
|
||||
id = TRENDING_STATUSES,
|
||||
text = R.string.title_public_trending_statuses,
|
||||
icon = R.drawable.ic_hot_24dp,
|
||||
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) }
|
||||
)
|
||||
HASHTAG -> TabData(
|
||||
id = HASHTAG,
|
||||
|
@ -140,6 +146,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
|||
title = { arguments.getOrNull(1).orEmpty() },
|
||||
enableStreaming = enableStreaming,
|
||||
)
|
||||
BOOKMARKS -> TabData(
|
||||
id = BOOKMARKS,
|
||||
text = R.string.title_bookmarks,
|
||||
icon = R.drawable.ic_bookmark_active_24dp,
|
||||
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) }
|
||||
)
|
||||
else -> throw IllegalArgumentException("unknown tab type")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -164,18 +164,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
toggleFab(false)
|
||||
}
|
||||
|
||||
binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT)
|
||||
|
||||
updateAvailableTabs()
|
||||
|
||||
onBackPressedDispatcher.addCallback(onFabDismissedCallback)
|
||||
}
|
||||
|
||||
override fun onTabAdded(tab: TabData) {
|
||||
if (currentTabs.size >= MAX_TAB_COUNT) {
|
||||
return
|
||||
}
|
||||
|
||||
toggleFab(false)
|
||||
|
||||
if (tab.id == HASHTAG) {
|
||||
|
@ -386,17 +380,23 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
if (!currentTabs.contains(directMessagesTab)) {
|
||||
addableTabs.add(directMessagesTab)
|
||||
}
|
||||
val trendingTab = createTabDataFromId(TRENDING)
|
||||
if (!currentTabs.contains(trendingTab)) {
|
||||
addableTabs.add(trendingTab)
|
||||
val trendingTagsTab = createTabDataFromId(TRENDING_TAGS)
|
||||
if (!currentTabs.contains(trendingTagsTab)) {
|
||||
addableTabs.add(trendingTagsTab)
|
||||
}
|
||||
val bookmarksTab = createTabDataFromId(BOOKMARKS)
|
||||
if (!currentTabs.contains(trendingTagsTab)) {
|
||||
addableTabs.add(bookmarksTab)
|
||||
}
|
||||
val trendingStatusesTab = createTabDataFromId(TRENDING_STATUSES)
|
||||
if (!currentTabs.contains(trendingStatusesTab)) {
|
||||
addableTabs.add(trendingStatusesTab)
|
||||
}
|
||||
|
||||
addableTabs.add(createTabDataFromId(HASHTAG))
|
||||
addableTabs.add(createTabDataFromId(LIST))
|
||||
|
||||
addTabAdapter.updateData(addableTabs)
|
||||
|
||||
binding.maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT)
|
||||
currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT)
|
||||
}
|
||||
|
||||
|
@ -429,6 +429,5 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
companion object {
|
||||
private const val MIN_TAB_COUNT = 2
|
||||
private const val MAX_TAB_COUNT = 9
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,9 +25,11 @@ import androidx.work.WorkManager
|
|||
import autodispose2.AutoDisposePlugins
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.di.AppInjector
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
|
||||
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.setAppNightMode
|
||||
import com.keylesspalace.tusky.worker.PruneCacheWorker
|
||||
|
@ -76,7 +78,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
AppInjector.init(this)
|
||||
|
||||
// Migrate shared preference keys and defaults from version to version.
|
||||
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
|
||||
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION)
|
||||
if (oldVersion != SCHEMA_VERSION) {
|
||||
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
|
||||
}
|
||||
|
@ -87,7 +89,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
||||
|
||||
// init night mode
|
||||
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value)
|
||||
setAppNightMode(theme)
|
||||
|
||||
localeManager.setLocale()
|
||||
|
@ -130,6 +132,20 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
|
||||
}
|
||||
|
||||
if (oldVersion < 2023072401) {
|
||||
// The notifications filter / clear options are shown on a menu, not a separate bar,
|
||||
// the preference to display them is not needed.
|
||||
editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER)
|
||||
}
|
||||
|
||||
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
|
||||
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
|
||||
// didn't have an explicit preference set use the previous default, so the
|
||||
// theme does not unexpectedly change.
|
||||
if (!sharedPreferences.contains(APP_THEME)) {
|
||||
editor.putString(APP_THEME, AppTheme.NIGHT.value)
|
||||
}
|
||||
}
|
||||
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
|
||||
editor.apply()
|
||||
}
|
||||
|
|
|
@ -59,6 +59,8 @@ import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
|
|||
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
|
@ -67,10 +69,13 @@ import java.io.FileNotFoundException
|
|||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
|
||||
|
||||
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
|
||||
class ViewMediaActivity : BaseActivity(), HasAndroidInjector, ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
|
||||
|
||||
|
@ -337,6 +342,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
shareFile(file, mimeType)
|
||||
}
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ATTACHMENTS = "attachments"
|
||||
private const val EXTRA_ATTACHMENT_INDEX = "index"
|
||||
|
|
|
@ -47,7 +47,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
|
|||
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
|
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
|
||||
}
|
||||
|
|
|
@ -21,12 +21,10 @@ import android.text.Spanned
|
|||
import android.text.style.StyleSpan
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowRequestViewHolder(
|
||||
private val binding: ItemFollowRequestBinding,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
private val showHeader: Boolean
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithAccount(
|
||||
viewData.account,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis,
|
||||
statusDisplayOptions.showBotOverlay
|
||||
)
|
||||
|
||||
setupActionListener(accountActionListener, viewData.account.id)
|
||||
}
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setupWithAccount(
|
||||
account: TimelineAccount,
|
||||
|
@ -70,24 +47,12 @@ class FollowRequestViewHolder(
|
|||
showBotOverlay: Boolean
|
||||
) {
|
||||
val wrappedName = account.name.unicodeWrap()
|
||||
val emojifiedName: CharSequence = wrappedName.emojify(
|
||||
account.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
|
||||
binding.displayNameTextView.text = emojifiedName
|
||||
if (showHeader) {
|
||||
val wholeMessage: String = itemView.context.getString(
|
||||
R.string.notification_follow_request_format,
|
||||
wrappedName
|
||||
)
|
||||
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
|
||||
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
||||
setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
0,
|
||||
wrappedName.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}.emojify(account.emojis, itemView, animateEmojis)
|
||||
}
|
||||
binding.notificationTextView.visible(showHeader)
|
||||
|
|
|
@ -0,0 +1,709 @@
|
|||
/* Copyright 2021 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 android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.InputFilter;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount;
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener;
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
||||
public class NotificationsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements LinkListener{
|
||||
|
||||
public interface AdapterDataSource<T> {
|
||||
int getItemCount();
|
||||
|
||||
T getItemAt(int pos);
|
||||
}
|
||||
|
||||
|
||||
private static final int VIEW_TYPE_STATUS = 0;
|
||||
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
|
||||
private static final int VIEW_TYPE_FOLLOW = 2;
|
||||
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
|
||||
private static final int VIEW_TYPE_PLACEHOLDER = 4;
|
||||
private static final int VIEW_TYPE_REPORT = 5;
|
||||
private static final int VIEW_TYPE_UNKNOWN = 6;
|
||||
|
||||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||
|
||||
private final String accountId;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private final StatusActionListener statusListener;
|
||||
private final NotificationActionListener notificationActionListener;
|
||||
private final AccountActionListener accountActionListener;
|
||||
private final AdapterDataSource<NotificationViewData> dataSource;
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
||||
public NotificationsAdapter(String accountId,
|
||||
AdapterDataSource<NotificationViewData> dataSource,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
StatusActionListener statusListener,
|
||||
NotificationActionListener notificationActionListener,
|
||||
AccountActionListener accountActionListener) {
|
||||
|
||||
this.accountId = accountId;
|
||||
this.dataSource = dataSource;
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.statusListener = statusListener;
|
||||
this.notificationActionListener = notificationActionListener;
|
||||
this.accountActionListener = accountActionListener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
switch (viewType) {
|
||||
case VIEW_TYPE_STATUS: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status, parent, false);
|
||||
return new StatusViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status_notification, parent, false);
|
||||
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_follow, parent, false);
|
||||
return new FollowViewHolder(view, statusDisplayOptions);
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW_REQUEST: {
|
||||
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
|
||||
return new FollowRequestViewHolder(binding, this, true);
|
||||
}
|
||||
case VIEW_TYPE_PLACEHOLDER: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status_placeholder, parent, false);
|
||||
return new PlaceholderViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_REPORT: {
|
||||
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
|
||||
return new ReportNotificationViewHolder(binding);
|
||||
}
|
||||
default:
|
||||
case VIEW_TYPE_UNKNOWN: {
|
||||
View view = new View(parent.getContext());
|
||||
view.setLayoutParams(
|
||||
new ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
Utils.dpToPx(parent.getContext(), 24)
|
||||
)
|
||||
);
|
||||
return new RecyclerView.ViewHolder(view) {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
bindViewHolder(viewHolder, position, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
|
||||
bindViewHolder(viewHolder, position, payloads);
|
||||
}
|
||||
|
||||
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
|
||||
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
|
||||
if (position < this.dataSource.getItemCount()) {
|
||||
NotificationViewData notification = dataSource.getItemAt(position);
|
||||
if (notification instanceof NotificationViewData.Placeholder) {
|
||||
if (payloadForHolder == null) {
|
||||
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
|
||||
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
|
||||
holder.setup(statusListener, placeholder.isLoading());
|
||||
}
|
||||
return;
|
||||
}
|
||||
NotificationViewData.Concrete concreteNotification =
|
||||
(NotificationViewData.Concrete) notification;
|
||||
switch (viewHolder.getItemViewType()) {
|
||||
case VIEW_TYPE_STATUS: {
|
||||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
|
||||
if (status == null) {
|
||||
/* in some very rare cases servers sends null status even though they should not,
|
||||
* we have to handle it somehow */
|
||||
holder.showStatusContent(false);
|
||||
} else {
|
||||
if (payloads == null) {
|
||||
holder.showStatusContent(true);
|
||||
}
|
||||
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
|
||||
}
|
||||
if (concreteNotification.getType() == Notification.Type.POLL) {
|
||||
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
|
||||
} else {
|
||||
holder.hideStatusInfo();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
|
||||
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
|
||||
if (payloadForHolder == null) {
|
||||
if (statusViewData == null) {
|
||||
/* in some very rare cases servers sends null status even though they should not,
|
||||
* we have to handle it somehow */
|
||||
holder.showNotificationContent(false);
|
||||
} else {
|
||||
holder.showNotificationContent(true);
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
|
||||
holder.setUsername(status.getAccount().getUsername());
|
||||
holder.setCreatedAt(status.getCreatedAt());
|
||||
|
||||
if (concreteNotification.getType() == Notification.Type.STATUS ||
|
||||
concreteNotification.getType() == Notification.Type.UPDATE) {
|
||||
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
|
||||
} else {
|
||||
holder.setAvatars(status.getAccount().getAvatar(),
|
||||
concreteNotification.getAccount().getAvatar());
|
||||
}
|
||||
}
|
||||
|
||||
holder.setMessage(concreteNotification, statusListener);
|
||||
holder.setupButtons(notificationActionListener,
|
||||
concreteNotification.getAccount().getId(),
|
||||
concreteNotification.getId());
|
||||
} else {
|
||||
if (payloadForHolder instanceof List)
|
||||
for (Object item : (List<?>) payloadForHolder) {
|
||||
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
|
||||
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW: {
|
||||
if (payloadForHolder == null) {
|
||||
FollowViewHolder holder = (FollowViewHolder) viewHolder;
|
||||
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
|
||||
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW_REQUEST: {
|
||||
if (payloadForHolder == null) {
|
||||
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
|
||||
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
|
||||
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VIEW_TYPE_REPORT: {
|
||||
if (payloadForHolder == null) {
|
||||
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
|
||||
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
|
||||
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return dataSource.getItemCount();
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
|
||||
this.statusDisplayOptions = statusDisplayOptions.copy(
|
||||
statusDisplayOptions.animateAvatars(),
|
||||
mediaPreviewEnabled,
|
||||
statusDisplayOptions.useAbsoluteTime(),
|
||||
statusDisplayOptions.showBotOverlay(),
|
||||
statusDisplayOptions.useBlurhash(),
|
||||
CardViewMode.NONE,
|
||||
statusDisplayOptions.confirmReblogs(),
|
||||
statusDisplayOptions.confirmFavourites(),
|
||||
statusDisplayOptions.hideStats(),
|
||||
statusDisplayOptions.animateEmojis(),
|
||||
statusDisplayOptions.showStatsInline(),
|
||||
statusDisplayOptions.showSensitiveMedia(),
|
||||
statusDisplayOptions.openSpoiler(),
|
||||
statusDisplayOptions.quoteEnabled()
|
||||
);
|
||||
}
|
||||
|
||||
public boolean isMediaPreviewEnabled() {
|
||||
return this.statusDisplayOptions.mediaPreviewEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
NotificationViewData notification = dataSource.getItemAt(position);
|
||||
if (notification instanceof NotificationViewData.Concrete) {
|
||||
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
|
||||
switch (concrete.getType()) {
|
||||
case MENTION:
|
||||
case POLL: {
|
||||
return VIEW_TYPE_STATUS;
|
||||
}
|
||||
case STATUS:
|
||||
case FAVOURITE:
|
||||
case REBLOG:
|
||||
case UPDATE: {
|
||||
return VIEW_TYPE_STATUS_NOTIFICATION;
|
||||
}
|
||||
case FOLLOW:
|
||||
case SIGN_UP: {
|
||||
return VIEW_TYPE_FOLLOW;
|
||||
}
|
||||
case FOLLOW_REQUEST: {
|
||||
return VIEW_TYPE_FOLLOW_REQUEST;
|
||||
}
|
||||
case REPORT: {
|
||||
return VIEW_TYPE_REPORT;
|
||||
}
|
||||
default: {
|
||||
return VIEW_TYPE_UNKNOWN;
|
||||
}
|
||||
}
|
||||
} else if (notification instanceof NotificationViewData.Placeholder) {
|
||||
return VIEW_TYPE_PLACEHOLDER;
|
||||
} else {
|
||||
throw new AssertionError("Unknown notification type");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public interface NotificationActionListener {
|
||||
void onViewAccount(String id);
|
||||
|
||||
void onViewStatusForNotificationId(String notificationId);
|
||||
|
||||
void onViewReport(String reportId);
|
||||
|
||||
void onExpandedChange(boolean expanded, int position);
|
||||
|
||||
/**
|
||||
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
|
||||
* status content is interacted with.
|
||||
*
|
||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
||||
* @param position The position of the status in the list.
|
||||
*/
|
||||
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
|
||||
}
|
||||
|
||||
private static class FollowViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView message;
|
||||
private final TextView usernameView;
|
||||
private final TextView displayNameView;
|
||||
private final ImageView avatar;
|
||||
private final StatusDisplayOptions statusDisplayOptions;
|
||||
|
||||
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
|
||||
super(itemView);
|
||||
message = itemView.findViewById(R.id.notification_text);
|
||||
usernameView = itemView.findViewById(R.id.notification_username);
|
||||
displayNameView = itemView.findViewById(R.id.notification_display_name);
|
||||
avatar = itemView.findViewById(R.id.notification_avatar);
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
}
|
||||
|
||||
void setMessage(TimelineAccount account, Boolean isSignUp) {
|
||||
Context context = message.getContext();
|
||||
|
||||
String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
|
||||
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
|
||||
String wholeMessage = String.format(format, wrappedDisplayName);
|
||||
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
|
||||
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
message.setText(emojifiedMessage);
|
||||
|
||||
String username = context.getString(R.string.post_username_format, account.getUsername());
|
||||
usernameView.setText(username);
|
||||
|
||||
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
|
||||
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
|
||||
displayNameView.setText(emojifiedDisplayName);
|
||||
|
||||
int avatarRadius = avatar.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
|
||||
statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
}
|
||||
|
||||
void setupButtons(final NotificationActionListener listener, final String accountId) {
|
||||
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
|
||||
}
|
||||
}
|
||||
|
||||
private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
|
||||
implements View.OnClickListener {
|
||||
|
||||
private final View container;
|
||||
private final TextView message;
|
||||
// private final View statusNameBar;
|
||||
private final TextView displayName;
|
||||
private final TextView username;
|
||||
private final TextView timestampInfo;
|
||||
private final TextView statusContent;
|
||||
private final ImageView statusAvatar;
|
||||
private final ImageView notificationAvatar;
|
||||
private final TextView contentWarningDescriptionTextView;
|
||||
private final Button contentWarningButton;
|
||||
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
|
||||
private final StatusDisplayOptions statusDisplayOptions;
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter;
|
||||
|
||||
private String accountId;
|
||||
private String notificationId;
|
||||
private NotificationActionListener notificationActionListener;
|
||||
private StatusViewData.Concrete statusViewData;
|
||||
|
||||
private final int avatarRadius48dp;
|
||||
private final int avatarRadius36dp;
|
||||
private final int avatarRadius24dp;
|
||||
|
||||
StatusNotificationViewHolder(
|
||||
View itemView,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
AbsoluteTimeFormatter absoluteTimeFormatter
|
||||
) {
|
||||
super(itemView);
|
||||
message = itemView.findViewById(R.id.notification_top_text);
|
||||
// statusNameBar = itemView.findViewById(R.id.status_name_bar);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
timestampInfo = itemView.findViewById(R.id.status_meta_info);
|
||||
statusContent = itemView.findViewById(R.id.notification_content);
|
||||
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
|
||||
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
|
||||
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
|
||||
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
|
||||
|
||||
container = itemView.findViewById(R.id.notification_container);
|
||||
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.absoluteTimeFormatter = absoluteTimeFormatter;
|
||||
|
||||
int darkerFilter = Color.rgb(123, 123, 123);
|
||||
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
||||
notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
||||
|
||||
itemView.setOnClickListener(this);
|
||||
message.setOnClickListener(this);
|
||||
statusContent.setOnClickListener(this);
|
||||
|
||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||
}
|
||||
|
||||
private void showNotificationContent(boolean show) {
|
||||
// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void setDisplayName(String name, List<Emoji> emojis) {
|
||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
|
||||
displayName.setText(emojifiedName);
|
||||
}
|
||||
|
||||
private void setUsername(String name) {
|
||||
Context context = username.getContext();
|
||||
String format = context.getString(R.string.post_username_format);
|
||||
String usernameText = String.format(format, name);
|
||||
username.setText(usernameText);
|
||||
}
|
||||
|
||||
protected void setCreatedAt(@Nullable Date createdAt) {
|
||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
|
||||
} else {
|
||||
// This is the visible timestampInfo.
|
||||
String readout;
|
||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
CharSequence readoutAloud;
|
||||
if (createdAt != null) {
|
||||
long then = createdAt.getTime();
|
||||
long now = new Date().getTime();
|
||||
readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
|
||||
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
|
||||
android.text.format.DateUtils.SECOND_IN_MILLIS,
|
||||
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||
} else {
|
||||
// unknown minutes~
|
||||
readout = "?m";
|
||||
readoutAloud = "? minutes";
|
||||
}
|
||||
timestampInfo.setText(readout);
|
||||
timestampInfo.setContentDescription(readoutAloud);
|
||||
}
|
||||
}
|
||||
|
||||
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
|
||||
Drawable icon = ContextCompat.getDrawable(context, drawable);
|
||||
if (icon != null) {
|
||||
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
|
||||
this.statusViewData = notificationViewData.getStatusViewData();
|
||||
|
||||
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
|
||||
Notification.Type type = notificationViewData.getType();
|
||||
|
||||
Context context = message.getContext();
|
||||
String format;
|
||||
Drawable icon;
|
||||
switch (type) {
|
||||
default:
|
||||
case FAVOURITE: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
|
||||
format = context.getString(R.string.notification_favourite_format);
|
||||
break;
|
||||
}
|
||||
case REBLOG: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_reblog_format);
|
||||
break;
|
||||
}
|
||||
case STATUS: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_subscription_format);
|
||||
break;
|
||||
}
|
||||
case UPDATE: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_update_format);
|
||||
break;
|
||||
}
|
||||
}
|
||||
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
|
||||
String wholeMessage = String.format(format, displayName);
|
||||
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
|
||||
int displayNameIndex = format.indexOf("%s");
|
||||
str.setSpan(
|
||||
new StyleSpan(Typeface.BOLD),
|
||||
displayNameIndex,
|
||||
displayNameIndex + displayName.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
);
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
message.setText(emojifiedText);
|
||||
|
||||
if (statusViewData != null) {
|
||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
|
||||
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||
if (statusViewData.isExpanded()) {
|
||||
contentWarningButton.setText(R.string.post_content_warning_show_less);
|
||||
} else {
|
||||
contentWarningButton.setText(R.string.post_content_warning_show_more);
|
||||
}
|
||||
|
||||
contentWarningButton.setOnClickListener(view -> {
|
||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||
notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition());
|
||||
}
|
||||
statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE);
|
||||
});
|
||||
|
||||
setupContentAndSpoiler(listener);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void setupButtons(final NotificationActionListener listener, final String accountId,
|
||||
final String notificationId) {
|
||||
this.notificationActionListener = listener;
|
||||
this.accountId = accountId;
|
||||
this.notificationId = notificationId;
|
||||
}
|
||||
|
||||
void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) {
|
||||
statusAvatar.setPaddingRelative(0, 0, 0, 0);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||
statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
Glide.with(notificationAvatar)
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(notificationAvatar);
|
||||
|
||||
} else {
|
||||
notificationAvatar.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
|
||||
int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
|
||||
statusAvatar.setPaddingRelative(0, 0, padding, padding);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||
statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
||||
avatarRadius24dp, statusDisplayOptions.animateAvatars(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (notificationActionListener == null)
|
||||
return;
|
||||
|
||||
if (v == container || v == statusContent) {
|
||||
notificationActionListener.onViewStatusForNotificationId(notificationId);
|
||||
}
|
||||
else if (v == message) {
|
||||
notificationActionListener.onViewAccount(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupContentAndSpoiler(final LinkListener listener) {
|
||||
|
||||
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
|
||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
|
||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||
statusContent.setVisibility(View.GONE);
|
||||
} else {
|
||||
statusContent.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
Spanned content = statusViewData.getContent();
|
||||
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
|
||||
|
||||
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
|
||||
contentCollapseButton.setOnClickListener(view -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
|
||||
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
|
||||
}
|
||||
});
|
||||
|
||||
contentCollapseButton.setVisibility(View.VISIBLE);
|
||||
if (statusViewData.isCollapsed()) {
|
||||
contentCollapseButton.setText(R.string.post_content_warning_show_more);
|
||||
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
|
||||
} else {
|
||||
contentCollapseButton.setText(R.string.post_content_warning_show_less);
|
||||
statusContent.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
} else {
|
||||
contentCollapseButton.setVisibility(View.GONE);
|
||||
statusContent.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
|
||||
|
||||
CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify(
|
||||
statusViewData.getStatus().getSpoilerText(),
|
||||
statusViewData.getActionable().getEmojis(),
|
||||
contentWarningDescriptionTextView,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onViewTag(@NonNull String tag) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewAccount(@NonNull String id) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewUrl(@NonNull String url, @NonNull String text) {
|
||||
|
||||
}
|
||||
}
|
|
@ -20,76 +20,28 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||
import com.keylesspalace.tusky.entity.Report
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import java.util.Date
|
||||
|
||||
class ReportNotificationViewHolder(
|
||||
private val binding: ItemReportNotificationBinding,
|
||||
private val notificationActionListener: NotificationActionListener
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithReport(
|
||||
viewData.account,
|
||||
viewData.report!!,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis
|
||||
)
|
||||
setupActionListener(
|
||||
notificationActionListener,
|
||||
viewData.report.targetAccount.id,
|
||||
viewData.account.id,
|
||||
viewData.report.id
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupWithReport(
|
||||
reporter: TimelineAccount,
|
||||
report: Report,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(
|
||||
reporter.emojis,
|
||||
binding.root,
|
||||
animateEmojis
|
||||
)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
||||
report.targetAccount.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
|
||||
fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
|
||||
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
||||
|
||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
||||
binding.notificationTopText.text = itemView.context.getString(
|
||||
R.string.notification_header_report_format,
|
||||
reporterName,
|
||||
reporteeName
|
||||
)
|
||||
binding.notificationSummary.text = itemView.context.getString(
|
||||
R.string.notification_summary_report_format,
|
||||
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
|
||||
report.status_ids?.size ?: 0
|
||||
)
|
||||
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
||||
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0)
|
||||
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
||||
|
||||
// Fancy avatar inset
|
||||
|
@ -100,22 +52,17 @@ class ReportNotificationViewHolder(
|
|||
report.targetAccount.avatar,
|
||||
binding.notificationReporteeAvatar,
|
||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
|
||||
animateAvatar
|
||||
animateAvatar,
|
||||
)
|
||||
loadAvatar(
|
||||
reporter.avatar,
|
||||
binding.notificationReporterAvatar,
|
||||
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
|
||||
animateAvatar
|
||||
animateAvatar,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupActionListener(
|
||||
listener: NotificationActionListener,
|
||||
reporteeId: String,
|
||||
reporterId: String,
|
||||
reportId: String
|
||||
) {
|
||||
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
|
||||
binding.notificationReporteeAvatar.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
|
|
|
@ -58,6 +58,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
|||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||
import com.keylesspalace.tusky.util.AttachmentHelper;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
|
@ -73,6 +74,7 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
|
@ -125,10 +127,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private final TextView cardDescription;
|
||||
private final TextView cardUrl;
|
||||
private final PollAdapter pollAdapter;
|
||||
protected LinearLayout filteredPlaceholder;
|
||||
protected TextView filteredPlaceholderLabel;
|
||||
protected Button filteredPlaceholderShowButton;
|
||||
protected ConstraintLayout statusContainer;
|
||||
protected final LinearLayout filteredPlaceholder;
|
||||
protected final TextView filteredPlaceholderLabel;
|
||||
protected final Button filteredPlaceholderShowButton;
|
||||
protected final ConstraintLayout statusContainer;
|
||||
|
||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
@ -139,7 +141,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
private final Drawable mediaPreviewUnloaded;
|
||||
|
||||
protected StatusBaseViewHolder(View itemView) {
|
||||
protected StatusBaseViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
|
@ -203,14 +205,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
|
||||
}
|
||||
|
||||
protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
|
||||
protected void setDisplayName(@NonNull String name, @Nullable List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(
|
||||
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
displayName.setText(emojifiedName);
|
||||
}
|
||||
|
||||
protected void setUsername(String name) {
|
||||
protected void setUsername(@Nullable String name) {
|
||||
Context context = username.getContext();
|
||||
String usernameText = context.getString(R.string.post_username_format, name);
|
||||
username.setText(usernameText);
|
||||
|
@ -222,10 +224,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener) {
|
||||
final @NonNull StatusActionListener listener) {
|
||||
|
||||
Status actionable = status.getActionable();
|
||||
String spoilerText = status.getSpoilerText();
|
||||
String spoilerText = actionable.getSpoilerText();
|
||||
List<Emoji> emojis = actionable.getEmojis();
|
||||
|
||||
boolean sensitive = !TextUtils.isEmpty(spoilerText);
|
||||
|
@ -342,17 +344,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
avatarInset.setVisibility(View.VISIBLE);
|
||||
avatarInset.setBackground(null);
|
||||
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
avatarRadius = avatarRadius36dp;
|
||||
}
|
||||
|
||||
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
|
||||
statusDisplayOptions.animateAvatars(),
|
||||
Collections.singletonList(new CompositeWithOpaqueBackground(avatar)));
|
||||
}
|
||||
|
||||
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
|
||||
|
@ -591,9 +593,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected void setMediaPreviews(
|
||||
final List<Attachment> attachments,
|
||||
final @NonNull List<Attachment> attachments,
|
||||
boolean sensitive,
|
||||
final StatusActionListener listener,
|
||||
final @NonNull StatusActionListener listener,
|
||||
boolean showingContent,
|
||||
boolean useBlurhash
|
||||
) {
|
||||
|
@ -684,8 +686,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
mediaLabels[index].setText(label);
|
||||
}
|
||||
|
||||
protected void setMediaLabel(List<Attachment> attachments, boolean sensitive,
|
||||
final StatusActionListener listener, boolean showingContent) {
|
||||
protected void setMediaLabel(@NonNull List<Attachment> attachments, boolean sensitive,
|
||||
final @NonNull StatusActionListener listener, boolean showingContent) {
|
||||
Context context = itemView.getContext();
|
||||
for (int i = 0; i < mediaLabels.length; i++) {
|
||||
TextView mediaLabel = mediaLabels[i];
|
||||
|
@ -706,7 +708,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private void setAttachmentClickListener(View view, StatusActionListener listener,
|
||||
private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener,
|
||||
int index, Attachment attachment, boolean animateTransition) {
|
||||
view.setOnClickListener(v -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
|
@ -730,11 +732,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
sensitiveMediaShow.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
protected void setupButtons(final StatusActionListener listener,
|
||||
final String accountId,
|
||||
final String statusContent,
|
||||
final boolean isNotestock,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
protected void setupButtons(final @NonNull StatusActionListener listener,
|
||||
final @NonNull String accountId,
|
||||
final @NonNull String statusContent,
|
||||
final @NonNull boolean isNotestock,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
View.OnClickListener profileButtonClickListener = button -> {
|
||||
if (isNotestock) {
|
||||
listener.onViewUrl(accountId, accountId);
|
||||
|
@ -851,6 +853,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
listener.onReblog(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
reblogButton.playAnimation();
|
||||
reblogButton.setChecked(true);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
@ -872,14 +875,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
listener.onFavourite(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
favouriteButton.playAnimation();
|
||||
favouriteButton.setChecked(true);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
popup.show();
|
||||
}
|
||||
|
||||
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
this.setupWithStatus(status, listener, statusDisplayOptions, null);
|
||||
}
|
||||
|
||||
|
@ -890,7 +894,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (payloads == null) {
|
||||
Status actionable = status.getActionable();
|
||||
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
|
||||
setUsername(status.getUsername());
|
||||
setUsername(actionable.getAccount().getUsername());
|
||||
setMetaData(status, statusDisplayOptions, listener);
|
||||
setIsReply(actionable.getInReplyToId() != null);
|
||||
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
|
||||
|
@ -968,12 +972,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
|
||||
filteredPlaceholderShowButton.setOnClickListener(view -> {
|
||||
listener.clearWarningAction(getBindingAdapterPosition());
|
||||
});
|
||||
filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition()));
|
||||
}
|
||||
|
||||
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
|
||||
protected static boolean hasPreviewableAttachment(@NonNull List<Attachment> attachments) {
|
||||
for (Attachment attachment : attachments) {
|
||||
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
|
||||
return false;
|
||||
|
@ -990,11 +992,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
String description = context.getString(R.string.description_status,
|
||||
actionable.getAccount().getName(),
|
||||
getContentWarningDescription(context, status),
|
||||
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
(TextUtils.isEmpty(actionable.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
||||
getReblogDescription(context, status),
|
||||
status.getUsername(),
|
||||
actionable.getAccount().getUsername(),
|
||||
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
|
||||
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
|
||||
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
|
||||
|
@ -1041,14 +1043,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
private static CharSequence getContentWarningDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
if (!TextUtils.isEmpty(status.getSpoilerText())) {
|
||||
return context.getString(R.string.description_post_cw, status.getSpoilerText());
|
||||
if (!TextUtils.isEmpty(status.getActionable().getSpoilerText())) {
|
||||
return context.getString(R.string.description_post_cw, status.getActionable().getSpoilerText());
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) {
|
||||
@NonNull
|
||||
protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) {
|
||||
|
||||
if (visibility == null) {
|
||||
return "";
|
||||
|
@ -1097,7 +1100,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
protected CharSequence getFavsText(Context context, int count) {
|
||||
@NonNull
|
||||
protected CharSequence getFavsText(@NonNull Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
|
@ -1106,7 +1110,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
protected CharSequence getReblogsText(Context context, int count) {
|
||||
@NonNull
|
||||
protected CharSequence getReblogsText(@NonNull Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
|
@ -1207,11 +1212,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected void setupCard(
|
||||
final StatusViewData.Concrete status,
|
||||
final @NonNull StatusViewData.Concrete status,
|
||||
boolean expanded,
|
||||
final CardViewMode cardViewMode,
|
||||
final StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener
|
||||
final @NonNull CardViewMode cardViewMode,
|
||||
final @NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
final @NonNull StatusActionListener listener
|
||||
) {
|
||||
if (cardView == null) {
|
||||
return;
|
||||
|
|
|
@ -36,7 +36,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
|
||||
|
||||
public StatusDetailedViewHolder(View view) {
|
||||
public StatusDetailedViewHolder(@NonNull View view) {
|
||||
super(view);
|
||||
reblogs = view.findViewById(R.id.status_reblogs);
|
||||
favourites = view.findViewById(R.id.status_favourites);
|
||||
|
@ -44,7 +44,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
private final TextView favouritedCountLabel;
|
||||
private final TextView reblogsCountLabel;
|
||||
|
||||
public StatusViewHolder(View itemView) {
|
||||
public StatusViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
statusInfo = itemView.findViewById(R.id.status_info);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||
|
|
|
@ -26,12 +26,14 @@ class CacheUpdater @Inject constructor(
|
|||
eventHub.events.collect { event ->
|
||||
val accountId = accountManager.activeAccount?.id ?: return@collect
|
||||
when (event) {
|
||||
is FavoriteEvent ->
|
||||
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
|
||||
is ReblogEvent ->
|
||||
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
|
||||
is BookmarkEvent ->
|
||||
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
|
||||
is StatusChangedEvent -> {
|
||||
val status = event.status
|
||||
timelineDao.update(
|
||||
accountId = accountId,
|
||||
status = status,
|
||||
gson = gson
|
||||
)
|
||||
}
|
||||
is UnfollowEvent ->
|
||||
timelineDao.removeAllByUser(accountId, event.accountId)
|
||||
is StatusDeletedEvent ->
|
||||
|
@ -40,8 +42,6 @@ class CacheUpdater @Inject constructor(
|
|||
val pollString = gson.toJson(event.poll)
|
||||
timelineDao.setVoted(accountId, event.statusId, pollString)
|
||||
}
|
||||
is PinEvent ->
|
||||
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,13 +2,12 @@ package com.keylesspalace.tusky.appstore
|
|||
|
||||
import com.keylesspalace.tusky.TabData
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import net.accelf.yuito.streaming.Subscription
|
||||
|
||||
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event
|
||||
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event
|
||||
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event
|
||||
data class StatusChangedEvent(val status: Status) : Event
|
||||
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
|
||||
data class UnfollowEvent(val accountId: String) : Event
|
||||
data class BlockEvent(val accountId: String) : Event
|
||||
|
@ -16,13 +15,15 @@ data class MuteEvent(val accountId: String) : Event
|
|||
data class StatusDeletedEvent(val statusId: String) : Event
|
||||
data class StatusComposedEvent(val status: Status) : Event
|
||||
data class StatusScheduledEvent(val status: Status) : Event
|
||||
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
|
||||
data class ProfileEditedEvent(val newProfileData: Account) : Event
|
||||
data class PreferenceChangedEvent(val preferenceKey: String) : Event
|
||||
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Event
|
||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
|
||||
data class DomainMuteEvent(val instance: String) : Event
|
||||
data class AnnouncementReadEvent(val announcementId: String) : Event
|
||||
data class PinEvent(val statusId: String, val pinned: Boolean) : Event
|
||||
data class FilterUpdatedEvent(val filterContext: List<String>) : Event
|
||||
data class NewNotificationsEvent(val accountId: String, val notifications: List<Notification>) : Event
|
||||
data class ConversationsLoadingEvent(val accountId: String) : Event
|
||||
data class NotificationsLoadingEvent(val accountId: String) : Event
|
||||
data class QuickReplyEvent(val status: Status) : Event
|
||||
data class StreamUpdateEvent(val status: Status, val subscription: Subscription, val streamId: Int) : Event
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import javax.inject.Inject
|
||||
|
@ -13,7 +15,16 @@ class EventHub @Inject constructor() {
|
|||
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
||||
val events: Flow<Event> = sharedEventFlow
|
||||
|
||||
// TODO remove this old stuff as soon as NotificationsFragment is Kotlin
|
||||
private val eventsSubject = PublishSubject.create<Event>()
|
||||
val eventsObservable: Observable<Event> = eventsSubject
|
||||
|
||||
suspend fun dispatch(event: Event) {
|
||||
sharedEventFlow.emit(event)
|
||||
eventsSubject.onNext(event)
|
||||
}
|
||||
|
||||
fun dispatchOld(event: Event) {
|
||||
eventsSubject.onNext(event)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,9 +22,12 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.TextWatcher
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
|
@ -32,10 +35,12 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
|
@ -48,6 +53,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
|
@ -173,9 +179,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
|
||||
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false)
|
||||
animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
hideFab = sharedPrefs.getBoolean("fabHide", false)
|
||||
hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
|
||||
handleWindowInsets()
|
||||
setupToolbar()
|
||||
|
@ -475,10 +481,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
accountFieldAdapter.notifyDataSetChanged()
|
||||
|
||||
binding.accountLockedImageView.visible(account.locked)
|
||||
binding.accountBadgeTextView.visible(account.bot)
|
||||
|
||||
updateAccountAvatar()
|
||||
updateToolbar()
|
||||
updateBadges()
|
||||
updateMovedAccount()
|
||||
updateRemoteAccount()
|
||||
updateAccountJoinedDate()
|
||||
|
@ -491,6 +497,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateBadges() {
|
||||
binding.accountBadgeContainer.removeAllViews()
|
||||
|
||||
val isLight = resources.getBoolean(R.bool.lightNavigationBar)
|
||||
|
||||
if (loadedAccount?.bot == true) {
|
||||
val badgeView = getBadge(getColor(R.color.tusky_grey_50), R.drawable.ic_bot_24dp, getString(R.string.profile_badge_bot_text), isLight)
|
||||
binding.accountBadgeContainer.addView(badgeView)
|
||||
}
|
||||
|
||||
loadedAccount?.roles?.forEach { role ->
|
||||
val badgeColor = if (role.color.isNotBlank()) {
|
||||
Color.parseColor(role.color)
|
||||
} else {
|
||||
// sometimes the color is not set for a role, in this case fall back to our default blue
|
||||
getColor(R.color.tusky_blue)
|
||||
}
|
||||
|
||||
val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}")
|
||||
sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0)
|
||||
|
||||
val badgeView = getBadge(badgeColor, R.drawable.profile_badge_person_24dp, sb, isLight)
|
||||
|
||||
binding.accountBadgeContainer.addView(badgeView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAccountJoinedDate() {
|
||||
loadedAccount?.let { account ->
|
||||
try {
|
||||
|
@ -766,13 +799,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
loadedAccount?.let { loadedAccount ->
|
||||
val muteDomain = menu.findItem(R.id.action_mute_domain)
|
||||
domain = getDomain(loadedAccount.url)
|
||||
if (domain.isEmpty()) {
|
||||
when {
|
||||
// If we can't get the domain, there's no way we can mute it anyway...
|
||||
// If the account is from our own domain, muting it is no-op
|
||||
domain.isEmpty() || viewModel.isFromOwnDomain -> {
|
||||
menu.removeItem(R.id.action_mute_domain)
|
||||
} else {
|
||||
if (blockingDomain) {
|
||||
}
|
||||
blockingDomain -> {
|
||||
muteDomain.title = getString(R.string.action_unmute_domain, domain)
|
||||
} else {
|
||||
}
|
||||
else -> {
|
||||
muteDomain.title = getString(R.string.action_mute_domain, domain)
|
||||
}
|
||||
}
|
||||
|
@ -994,6 +1030,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
}
|
||||
|
||||
private fun getBadge(
|
||||
@ColorInt baseColor: Int,
|
||||
@DrawableRes icon: Int,
|
||||
text: CharSequence,
|
||||
isLight: Boolean
|
||||
): Chip {
|
||||
val badge = Chip(this)
|
||||
|
||||
// text color with maximum contrast
|
||||
val textColor = if (isLight) Color.BLACK else Color.WHITE
|
||||
// badge color with 50% transparency so it blends in with the theme background
|
||||
val backgroundColor = Color.argb(128, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor))
|
||||
// a color between the text color and the badge color
|
||||
val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f)
|
||||
|
||||
// configure the badge
|
||||
badge.text = text
|
||||
badge.setTextColor(textColor)
|
||||
badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width)
|
||||
badge.chipStrokeColor = ColorStateList.valueOf(outlineColor)
|
||||
badge.setChipIconResource(icon)
|
||||
badge.isChipIconVisible = true
|
||||
badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size)
|
||||
badge.chipIconTint = ColorStateList.valueOf(outlineColor)
|
||||
badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor)
|
||||
|
||||
// badge isn't clickable, so disable all related behavior
|
||||
badge.isClickable = false
|
||||
badge.isFocusable = false
|
||||
badge.setEnsureMinTouchTargetSize(false)
|
||||
|
||||
// reset some chip defaults so it looks better for our badge usecase
|
||||
badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding)
|
||||
badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding)
|
||||
badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height)
|
||||
badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height)
|
||||
badge.updatePadding(top = 0, bottom = 0)
|
||||
return badge
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -19,6 +19,7 @@ import com.keylesspalace.tusky.util.Error
|
|||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.getDomain
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -27,7 +28,7 @@ import javax.inject.Inject
|
|||
class AccountViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val accountManager: AccountManager
|
||||
accountManager: AccountManager
|
||||
) : ViewModel() {
|
||||
|
||||
val accountData = MutableLiveData<Resource<Account>>()
|
||||
|
@ -41,8 +42,16 @@ class AccountViewModel @Inject constructor(
|
|||
lateinit var accountId: String
|
||||
var isSelf = false
|
||||
|
||||
/** the domain of the viewed account **/
|
||||
var domain = ""
|
||||
|
||||
/** True if the viewed account has the same domain as the active account */
|
||||
var isFromOwnDomain = false
|
||||
|
||||
private var noteUpdateJob: Job? = null
|
||||
|
||||
private val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
|
@ -62,6 +71,9 @@ class AccountViewModel @Inject constructor(
|
|||
mastodonApi.account(accountId)
|
||||
.fold(
|
||||
{ account ->
|
||||
domain = getDomain(account.url)
|
||||
isFromOwnDomain = domain == activeAccount.domain
|
||||
|
||||
accountData.postValue(Success(account))
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
|
@ -298,7 +310,7 @@ class AccountViewModel @Inject constructor(
|
|||
|
||||
fun setAccountInfo(accountId: String) {
|
||||
this.accountId = accountId
|
||||
this.isSelf = accountManager.activeAccount?.accountId == accountId
|
||||
this.isSelf = activeAccount.accountId == accountId
|
||||
reload(false)
|
||||
}
|
||||
|
||||
|
|
|
@ -82,13 +82,10 @@ class AccountMediaFragment :
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
|
||||
|
||||
adapter = AccountMediaGridAdapter(
|
||||
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
|
||||
useBlurhash = useBlurhash,
|
||||
context = view.context,
|
||||
onAttachmentClickListener = ::onAttachmentClick
|
||||
|
|
|
@ -24,7 +24,6 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
|||
import java.util.Random
|
||||
|
||||
class AccountMediaGridAdapter(
|
||||
private val alwaysShowSensitiveMedia: Boolean,
|
||||
private val useBlurhash: Boolean,
|
||||
context: Context,
|
||||
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
|
||||
|
@ -80,7 +79,7 @@ class AccountMediaGridAdapter(
|
|||
.into(imageView)
|
||||
|
||||
imageView.contentDescription = item.attachment.getFormattedDescription(context)
|
||||
} else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) {
|
||||
} else if (item.sensitive && !item.isRevealed) {
|
||||
overlay.show()
|
||||
overlay.setImageDrawable(mediaHiddenDrawable)
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.paging.LoadType
|
|||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import retrofit2.HttpException
|
||||
|
@ -27,9 +28,9 @@ import retrofit2.HttpException
|
|||
@OptIn(ExperimentalPagingApi::class)
|
||||
class AccountMediaRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val activeAccount: AccountEntity,
|
||||
private val viewModel: AccountMediaViewModel
|
||||
) : RemoteMediator<String, AttachmentViewData>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<String, AttachmentViewData>
|
||||
|
@ -58,7 +59,7 @@ class AccountMediaRemoteMediator(
|
|||
}
|
||||
|
||||
val attachments = statuses.flatMap { status ->
|
||||
AttachmentViewData.list(status)
|
||||
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia ?: false)
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
|
|
|
@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountMediaViewModel @Inject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
api: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
|
@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor(
|
|||
|
||||
var currentSource: AccountMediaPagingSource? = null
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val media = Pager(
|
||||
config = PagingConfig(
|
||||
|
@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor(
|
|||
currentSource = source
|
||||
}
|
||||
},
|
||||
remoteMediator = AccountMediaRemoteMediator(api, this)
|
||||
remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this)
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
|
|
|
@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
|
||||
val id: String? = intent.getStringExtra(EXTRA_ID)
|
||||
val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false)
|
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
supportActionBar?.apply {
|
||||
|
@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
}
|
||||
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
|
||||
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
companion object {
|
||||
private const val EXTRA_TYPE = "type"
|
||||
private const val EXTRA_ID = "id"
|
||||
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
|
||||
|
||||
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
|
||||
fun newIntent(context: Context, type: Type, id: String? = null): Intent {
|
||||
return Intent(context, AccountListActivity::class.java).apply {
|
||||
putExtra(EXTRA_TYPE, type)
|
||||
putExtra(EXTRA_ID, id)
|
||||
putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,7 +61,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
|||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountListFragment :
|
||||
|
@ -107,13 +106,15 @@ class AccountListFragment :
|
|||
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
adapter = when (type) {
|
||||
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.FOLLOW_REQUESTS -> {
|
||||
val headerAdapter = FollowRequestsHeaderAdapter(
|
||||
instanceName = accountManager.activeAccount!!.domain,
|
||||
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
|
||||
instanceName = activeAccount.domain,
|
||||
accountLocked = activeAccount.locked
|
||||
)
|
||||
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
|
||||
|
@ -330,7 +331,7 @@ class AccountListFragment :
|
|||
|
||||
val linkHeader = response.headers()["Link"]
|
||||
onFetchAccountsSuccess(accountList, linkHeader)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
onFetchAccountsFailure(exception)
|
||||
}
|
||||
}
|
||||
|
@ -404,14 +405,12 @@ class AccountListFragment :
|
|||
private const val TAG = "AccountList" // logging tag
|
||||
private const val ARG_TYPE = "type"
|
||||
private const val ARG_ID = "id"
|
||||
private const val ARG_ACCOUNT_LOCKED = "acc_locked"
|
||||
|
||||
fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment {
|
||||
fun newInstance(type: Type, id: String? = null): AccountListFragment {
|
||||
return AccountListFragment().apply {
|
||||
arguments = Bundle(3).apply {
|
||||
putSerializable(ARG_TYPE, type)
|
||||
putString(ARG_ID, id)
|
||||
putBoolean(ARG_ACCOUNT_LOCKED, accountLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,7 +44,6 @@ class FollowRequestsAdapter(
|
|||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
showHeader = false
|
||||
)
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.announcements
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.ContextThemeWrapper
|
||||
|
@ -29,6 +30,7 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.EmojiSpan
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
|
@ -50,14 +52,19 @@ class AnnouncementAdapter(
|
|||
private val animateEmojis: Boolean = false
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() {
|
||||
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> {
|
||||
val binding = ItemAnnouncementBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemAnnouncementBinding>, position: Int) {
|
||||
val item = items[position]
|
||||
|
||||
holder.binding.announcementDate.text = absoluteTimeFormatter.format(item.publishedAt, false)
|
||||
|
||||
val text = holder.binding.text
|
||||
val chips = holder.binding.chipGroup
|
||||
val addReactionChip = holder.binding.addReactionChip
|
||||
|
|
|
@ -129,7 +129,7 @@ class AnnouncementsActivity :
|
|||
is Error -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.errorMessageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
|
||||
refreshAnnouncements()
|
||||
}
|
||||
binding.errorMessageView.show()
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.app.ProgressDialog
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
|
@ -97,8 +96,9 @@ import com.keylesspalace.tusky.entity.Attachment
|
|||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
|
||||
import com.keylesspalace.tusky.util.MentionSpan
|
||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.getInitialLanguages
|
||||
|
@ -212,24 +212,9 @@ class ComposeActivity :
|
|||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
|
||||
if (notificationId != -1) {
|
||||
// ComposeActivity was opened from a notification, delete the notification
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
// If started from an intent then compose as the account ID from the intent.
|
||||
// Otherwise use the active account. If null then the user is not logged in,
|
||||
// and return from the activity.
|
||||
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
|
||||
activeAccount = if (intentAccountId != -1L) {
|
||||
accountManager.getAccountById(intentAccountId)
|
||||
} else {
|
||||
accountManager.activeAccount
|
||||
} ?: return
|
||||
|
||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value)
|
||||
if (theme == "black") {
|
||||
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
||||
}
|
||||
|
@ -286,7 +271,7 @@ class ComposeActivity :
|
|||
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
|
||||
setupComposeField(preferences, viewModel.startingText)
|
||||
setupDefaultTagViews(preferences)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
|
@ -536,7 +521,12 @@ class ComposeActivity :
|
|||
if (throwable is UploadServerError) {
|
||||
displayTransientMessage(throwable.errorMessage)
|
||||
} else {
|
||||
displayTransientMessage(R.string.error_media_upload_sending)
|
||||
displayTransientMessage(
|
||||
getString(
|
||||
R.string.error_media_upload_sending_fmt,
|
||||
throwable.message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -662,7 +652,7 @@ class ComposeActivity :
|
|||
a.getDimensionPixelSize(0, 1)
|
||||
}
|
||||
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
loadAvatar(
|
||||
activeAccount.profilePictureUrl,
|
||||
binding.composeAvatar,
|
||||
|
@ -1148,9 +1138,28 @@ class ComposeActivity :
|
|||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
||||
private fun sanitizePickMediaDescription(description: String?): String? {
|
||||
if (description == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The Gboard android keyboard attaches this text whenever the user
|
||||
// pastes something from the keyboard's suggestion bar.
|
||||
// Due to different end user locales, the exact text may vary, but at
|
||||
// least in version 13.4.08, all of the translations contained the
|
||||
// string "Gboard".
|
||||
if ("Gboard" in description) {
|
||||
return null
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
private fun pickMedia(uri: Uri, description: String? = null) {
|
||||
var sanitizedDescription = sanitizePickMediaDescription(description)
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.pickMedia(uri, description).onFailure { throwable ->
|
||||
viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable ->
|
||||
val errorString = when (throwable) {
|
||||
is FileSizeException -> {
|
||||
val decimalFormat = DecimalFormat("0.##")
|
||||
|
@ -1435,8 +1444,6 @@ class ComposeActivity :
|
|||
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
|
||||
|
||||
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
|
||||
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
|
||||
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
|
||||
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
|
||||
private const val VISIBILITY_KEY = "VISIBILITY"
|
||||
private const val SCHEDULED_TIME_KEY = "SCHEDULE"
|
||||
|
@ -1450,26 +1457,15 @@ class ComposeActivity :
|
|||
|
||||
/**
|
||||
* @param options ComposeOptions to configure the ComposeActivity
|
||||
* @param notificationId the id of the notification that starts the Activity
|
||||
* @param accountId the id of the account to compose with, null for the current account
|
||||
* @return an Intent to start the ComposeActivity
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun startIntent(
|
||||
context: Context,
|
||||
options: ComposeOptions,
|
||||
notificationId: Int? = null,
|
||||
accountId: Long? = null
|
||||
options: ComposeOptions
|
||||
): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(COMPOSE_OPTIONS_EXTRA, options)
|
||||
if (notificationId != null) {
|
||||
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
|
||||
}
|
||||
if (accountId != null) {
|
||||
putExtra(ACCOUNT_ID_EXTRA, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,6 @@ import com.keylesspalace.tusky.service.ServiceClient
|
|||
import com.keylesspalace.tusky.service.StatusToSend
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -51,7 +50,6 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class ComposeViewModel @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
|
@ -106,7 +104,7 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
val domain = accountManager.activeAccount?.domain!!
|
||||
|
||||
lateinit var composeKind: ComposeKind
|
||||
private lateinit var composeKind: ComposeKind
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
@ -288,7 +286,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val mediaUris: MutableList<String> = mutableListOf()
|
||||
val mediaDescriptions: MutableList<String?> = mutableListOf()
|
||||
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
|
||||
media.value.forEach { item ->
|
||||
for (item in media.value) {
|
||||
mediaUris.add(item.uri.toString())
|
||||
mediaDescriptions.add(item.description)
|
||||
mediaFocus.add(item.focus)
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
|
@ -246,6 +247,7 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
@SuppressLint("Recycle") // stream is closed in ProgressRequestBody
|
||||
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
|
||||
return callbackFlow {
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
|
|
|
@ -75,10 +75,6 @@ class AddPollOptionsAdapter(
|
|||
}
|
||||
|
||||
private fun validateInput(): Boolean {
|
||||
if (options.contains("") || options.distinct().size != options.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return !(options.contains("") || options.distinct().size != options.size)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
|
@ -26,7 +25,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
|
@ -36,6 +35,8 @@ import com.bumptech.glide.request.target.CustomTarget
|
|||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
||||
|
@ -44,14 +45,29 @@ class CaptionDialog : DialogFragment() {
|
|||
private lateinit var listener: Listener
|
||||
private lateinit var input: EditText
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
private val binding by viewBinding(DialogImageDescriptionBinding::bind)
|
||||
|
||||
val binding = DialogImageDescriptionBinding.inflate(layoutInflater)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
|
||||
input.setText(it)
|
||||
}
|
||||
|
||||
return inflater.inflate(R.layout.dialog_image_description, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
input = binding.imageDescriptionText
|
||||
val imageView = binding.imageDescriptionView
|
||||
imageView.maximumScale = 6f
|
||||
imageView.maxZoom = 6f
|
||||
|
||||
input.hint = resources.getQuantityString(
|
||||
R.plurals.hint_describe_for_visually_impaired,
|
||||
|
@ -61,20 +77,19 @@ class CaptionDialog : DialogFragment() {
|
|||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
|
||||
|
||||
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
listener.onUpdateDescription(localId, input.text.toString())
|
||||
binding.cancelButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
|
||||
binding.okButton.setOnClickListener {
|
||||
listener.onUpdateDescription(localId, input.text.toString())
|
||||
dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
||||
isCancelable = true
|
||||
val window = dialog.window
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
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.
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
|
@ -90,9 +105,23 @@ class CaptionDialog : DialogFragment() {
|
|||
) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
})
|
||||
|
||||
return dialog
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
super.onLoadFailed(errorDrawable)
|
||||
imageView.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.apply {
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
@ -100,17 +129,6 @@ class CaptionDialog : DialogFragment() {
|
|||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
|
||||
input.setText(it)
|
||||
}
|
||||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener")
|
||||
|
|
|
@ -49,12 +49,12 @@ fun <T> T.makeFocusDialog(
|
|||
.load(previewUri)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
|
||||
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>, p3: Boolean): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
|
||||
val width = resource!!.intrinsicWidth
|
||||
override fun onResourceReady(resource: Drawable, model: Any, target: Target<Drawable?>?, dataSource: DataSource, isFirstResource: Boolean): Boolean {
|
||||
val width = resource.intrinsicWidth
|
||||
val height = resource.intrinsicHeight
|
||||
|
||||
dialogBinding.focusIndicator.setImageSize(width, height)
|
||||
|
|
|
@ -57,7 +57,9 @@ class ComposeScheduleView
|
|||
).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
private var scheduleDateTime: Calendar? = null
|
||||
|
||||
/** The date/time the user has chosen to schedule the status, in UTC */
|
||||
private var scheduleDateTimeUtc: Calendar? = null
|
||||
|
||||
init {
|
||||
binding.scheduledDateTime.setOnClickListener { openPickDateDialog() }
|
||||
|
@ -71,13 +73,13 @@ class ComposeScheduleView
|
|||
}
|
||||
|
||||
private fun updateScheduleUi() {
|
||||
if (scheduleDateTime == null) {
|
||||
if (scheduleDateTimeUtc == null) {
|
||||
binding.scheduledDateTime.text = ""
|
||||
binding.invalidScheduleWarning.visibility = GONE
|
||||
return
|
||||
}
|
||||
|
||||
val scheduled = scheduleDateTime!!.time
|
||||
val scheduled = scheduleDateTimeUtc!!.time
|
||||
binding.scheduledDateTime.text = String.format(
|
||||
"%s %s",
|
||||
dateFormat.format(scheduled),
|
||||
|
@ -98,21 +100,37 @@ class ComposeScheduleView
|
|||
}
|
||||
|
||||
fun resetSchedule() {
|
||||
scheduleDateTime = null
|
||||
scheduleDateTimeUtc = null
|
||||
updateScheduleUi()
|
||||
}
|
||||
|
||||
fun openPickDateDialog() {
|
||||
val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000
|
||||
// The earliest point in time the calendar should display. Start with current date/time
|
||||
val earliest = calendar().apply {
|
||||
// Add the minimum scheduling interval. This may roll the calendar over to the
|
||||
// next day (e.g. if the current time is 23:57).
|
||||
add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS)
|
||||
// Clear out the time components, so it's midnight
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val calendarConstraints = CalendarConstraints.Builder()
|
||||
.setValidator(
|
||||
DateValidatorPointForward.from(yesterday)
|
||||
)
|
||||
.setValidator(DateValidatorPointForward.from(earliest.timeInMillis))
|
||||
.build()
|
||||
initializeSuggestedTime()
|
||||
|
||||
// Work around a misfeature in MaterialDatePicker. The `selection` is treated as
|
||||
// millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC
|
||||
// instead of converting to the user's local timezone.
|
||||
//
|
||||
// So we have to add the TZ offset before setting it in the picker
|
||||
val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis)
|
||||
|
||||
val picker = MaterialDatePicker.Builder
|
||||
.datePicker()
|
||||
.setSelection(scheduleDateTime!!.timeInMillis)
|
||||
.setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset)
|
||||
.setCalendarConstraints(calendarConstraints)
|
||||
.build()
|
||||
picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) }
|
||||
|
@ -129,11 +147,12 @@ class ComposeScheduleView
|
|||
|
||||
private fun openPickTimeDialog() {
|
||||
val pickerBuilder = MaterialTimePicker.Builder()
|
||||
scheduleDateTime?.let {
|
||||
scheduleDateTimeUtc?.let {
|
||||
pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY])
|
||||
.setMinute(it[Calendar.MINUTE])
|
||||
}
|
||||
|
||||
pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis))
|
||||
pickerBuilder.setTimeFormat(getTimeFormat(context))
|
||||
|
||||
val picker = pickerBuilder.build()
|
||||
|
@ -154,7 +173,7 @@ class ComposeScheduleView
|
|||
fun setDateTime(scheduledAt: String?) {
|
||||
val date = getDateTime(scheduledAt) ?: return
|
||||
initializeSuggestedTime()
|
||||
scheduleDateTime!!.time = date
|
||||
scheduleDateTimeUtc!!.time = date
|
||||
updateScheduleUi()
|
||||
}
|
||||
|
||||
|
@ -180,24 +199,24 @@ class ComposeScheduleView
|
|||
// see https://github.com/material-components/material-components-android/issues/882
|
||||
newDate.timeZone = TimeZone.getTimeZone("UTC")
|
||||
newDate.timeInMillis = selection
|
||||
scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
|
||||
scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
|
||||
openPickTimeDialog()
|
||||
}
|
||||
|
||||
private fun onTimeSet(hourOfDay: Int, minute: Int) {
|
||||
initializeSuggestedTime()
|
||||
scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
scheduleDateTime?.set(Calendar.MINUTE, minute)
|
||||
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
|
||||
updateScheduleUi()
|
||||
listener?.onTimeSet(time)
|
||||
}
|
||||
|
||||
val time: String?
|
||||
get() = scheduleDateTime?.time?.let { iso8601.format(it) }
|
||||
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
|
||||
|
||||
private fun initializeSuggestedTime() {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduleDateTime = calendar().apply {
|
||||
if (scheduleDateTimeUtc == null) {
|
||||
scheduleDateTimeUtc = calendar().apply {
|
||||
add(Calendar.MINUTE, 15)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
if (payloads == null) {
|
||||
TimelineAccount account = status.getAccount();
|
||||
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener);
|
||||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
|
@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
ImageView avatarView = avatars[i];
|
||||
if (i < accounts.size()) {
|
||||
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
|
||||
avatarRadius48dp, statusDisplayOptions.animateAvatars());
|
||||
avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
||||
avatarView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
avatarView.setVisibility(View.GONE);
|
||||
|
|
|
@ -40,6 +40,7 @@ import com.google.android.material.color.MaterialColors
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
|
@ -55,6 +56,7 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.CardViewMode
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.isAnyLoading
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
|
@ -65,6 +67,7 @@ import com.mikepenz.iconics.utils.sizeDp
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
@ -100,14 +103,14 @@ class ConversationsFragment :
|
|||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||
cardViewMode = CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
||||
|
@ -130,12 +133,19 @@ class ConversationsFragment :
|
|||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (loadState.isAnyLoading()) {
|
||||
runBlocking {
|
||||
eventHub.dispatch(ConversationsLoadingEvent(accountManager.activeAccount?.accountId ?: ""))
|
||||
}
|
||||
}
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
when (loadState.refresh) {
|
||||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
binding.statusView.showHelp(R.string.help_empty_conversations)
|
||||
}
|
||||
}
|
||||
is LoadState.Error -> {
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
package com.keylesspalace.tusky.components.instancemute
|
||||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.os.Bundle
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceListActivity : BaseActivity(), HasAndroidInjector {
|
||||
class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
@ -28,7 +27,7 @@ class InstanceListActivity : BaseActivity(), HasAndroidInjector {
|
|||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, InstanceListFragment())
|
||||
.replace(R.id.fragment_container, DomainBlocksFragment())
|
||||
.commit()
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsAdapter.Companion.STRING_COMPARATOR
|
||||
import com.keylesspalace.tusky.databinding.ItemBlockedDomainBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
|
||||
class DomainBlocksAdapter(
|
||||
private val onUnmute: (String) -> Unit
|
||||
) : PagingDataAdapter<String, BindingHolder<ItemBlockedDomainBinding>>(STRING_COMPARATOR) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemBlockedDomainBinding> {
|
||||
val binding = ItemBlockedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemBlockedDomainBinding>, position: Int) {
|
||||
getItem(position)?.let { instance ->
|
||||
holder.binding.blockedDomain.text = instance
|
||||
holder.binding.blockedDomainUnblock.setOnClickListener {
|
||||
onUnmute(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val binding by viewBinding(FragmentDomainBlocksBinding::bind)
|
||||
|
||||
private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val adapter = DomainBlocksAdapter(viewModel::unblock)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(view.context)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiEvents.collect { event ->
|
||||
showSnackbar(event)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.domainPager.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0)
|
||||
|
||||
if (loadState.refresh is LoadState.Error) {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
val errorState = loadState.refresh as LoadState.Error
|
||||
binding.messageView.setup(errorState.error) { adapter.retry() }
|
||||
Log.w(TAG, "error loading blocked domains", errorState.error)
|
||||
} else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
|
||||
} else {
|
||||
binding.recyclerView.show()
|
||||
binding.messageView.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSnackbar(event: SnackbarEvent) {
|
||||
val message = if (event.throwable == null) {
|
||||
getString(event.message, event.domain)
|
||||
} else {
|
||||
Log.w(TAG, event.throwable)
|
||||
val error = event.throwable.localizedMessage ?: getString(R.string.ui_error_unknown)
|
||||
getString(event.message, event.domain, error)
|
||||
}
|
||||
|
||||
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
|
||||
.setTextMaxLines(5)
|
||||
.setAction(event.actionText, event.action)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DomainBlocksFragment"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
|
||||
class DomainBlocksPagingSource(
|
||||
private val domains: List<String>,
|
||||
private val nextKey: String?
|
||||
) : PagingSource<String, String>() {
|
||||
override fun getRefreshKey(state: PagingState<String, String>): String? = null
|
||||
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
|
||||
return if (params is LoadParams.Refresh) {
|
||||
LoadResult.Page(domains, null, nextKey)
|
||||
} else {
|
||||
LoadResult.Page(emptyList(), null, null)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class DomainBlocksRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val repository: DomainBlocksRepository
|
||||
) : RemoteMediator<String, String>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<String, String>
|
||||
): MediatorResult {
|
||||
return try {
|
||||
val response = request(loadType)
|
||||
?: return MediatorResult.Success(endOfPaginationReached = true)
|
||||
|
||||
return applyResponse(response)
|
||||
} catch (e: Exception) {
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun request(loadType: LoadType): Response<List<String>>? {
|
||||
return when (loadType) {
|
||||
LoadType.PREPEND -> null
|
||||
LoadType.APPEND -> api.domainBlocks(maxId = repository.nextKey)
|
||||
LoadType.REFRESH -> {
|
||||
repository.nextKey = null
|
||||
repository.domains.clear()
|
||||
api.domainBlocks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyResponse(response: Response<List<String>>): MediatorResult {
|
||||
val tags = response.body()
|
||||
if (!response.isSuccessful || tags == null) {
|
||||
return MediatorResult.Error(HttpException(response))
|
||||
}
|
||||
|
||||
val links = HttpHeaderLink.parse(response.headers()["Link"])
|
||||
repository.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
|
||||
repository.domains.addAll(tags)
|
||||
repository.invalidate()
|
||||
|
||||
return MediatorResult.Success(endOfPaginationReached = repository.nextKey == null)
|
||||
}
|
||||
}
|
|
@ -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.components.domainblocks
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.InvalidatingPagingSourceFactory
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import javax.inject.Inject
|
||||
|
||||
class DomainBlocksRepository @Inject constructor(
|
||||
private val api: MastodonApi
|
||||
) {
|
||||
val domains: MutableList<String> = mutableListOf()
|
||||
var nextKey: String? = null
|
||||
|
||||
private var factory = InvalidatingPagingSourceFactory {
|
||||
DomainBlocksPagingSource(domains.toList(), nextKey)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val domainPager = Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE),
|
||||
remoteMediator = DomainBlocksRemoteMediator(api, this),
|
||||
pagingSourceFactory = factory
|
||||
).flow
|
||||
|
||||
/** Invalidate the active paging source, see [PagingSource.invalidate] */
|
||||
fun invalidate() {
|
||||
factory.invalidate()
|
||||
}
|
||||
|
||||
suspend fun block(domain: String): NetworkResult<Unit> {
|
||||
return api.blockDomain(domain).onSuccess {
|
||||
domains.add(domain)
|
||||
factory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unblock(domain: String): NetworkResult<Unit> {
|
||||
return api.unblockDomain(domain).onSuccess {
|
||||
domains.remove(domain)
|
||||
factory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 20
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import com.keylesspalace.tusky.R
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class DomainBlocksViewModel @Inject constructor(
|
||||
private val repo: DomainBlocksRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val domainPager = repo.domainPager.cachedIn(viewModelScope)
|
||||
|
||||
val uiEvents = MutableSharedFlow<SnackbarEvent>()
|
||||
|
||||
fun block(domain: String) {
|
||||
viewModelScope.launch {
|
||||
repo.block(domain).onFailure { e ->
|
||||
uiEvents.emit(
|
||||
SnackbarEvent(
|
||||
message = R.string.error_blocking_domain,
|
||||
domain = domain,
|
||||
throwable = e,
|
||||
actionText = R.string.action_retry,
|
||||
action = { block(domain) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unblock(domain: String) {
|
||||
viewModelScope.launch {
|
||||
repo.unblock(domain).fold({
|
||||
uiEvents.emit(
|
||||
SnackbarEvent(
|
||||
message = R.string.confirmation_domain_unmuted,
|
||||
domain = domain,
|
||||
throwable = null,
|
||||
actionText = R.string.action_undo,
|
||||
action = { block(domain) }
|
||||
)
|
||||
)
|
||||
}, { e ->
|
||||
uiEvents.emit(
|
||||
SnackbarEvent(
|
||||
message = R.string.error_unblocking_domain,
|
||||
domain = domain,
|
||||
throwable = e,
|
||||
actionText = R.string.action_retry,
|
||||
action = { unblock(domain) }
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SnackbarEvent(
|
||||
@StringRes val message: Int,
|
||||
val domain: String,
|
||||
val throwable: Throwable?,
|
||||
@StringRes val actionText: Int,
|
||||
val action: (View) -> Unit
|
||||
)
|
|
@ -35,11 +35,11 @@ import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
|
|||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
class DraftsActivity : BaseActivity(), DraftActionListener {
|
||||
|
@ -131,7 +131,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
|
||||
Log.w(TAG, "failed loading reply information", throwable)
|
||||
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
// the original status to which a reply was drafted has been deleted
|
||||
// let's open the ComposeActivity without reply information
|
||||
Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface.BUTTON_POSITIVE
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
|
@ -18,16 +19,17 @@ import com.google.android.material.switchmaterial.SwitchMaterial
|
|||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
|
||||
import com.keylesspalace.tusky.databinding.DialogFilterBinding
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterKeyword
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -81,7 +83,11 @@ class EditFilterActivity : BaseActivity() {
|
|||
|
||||
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
|
||||
binding.filterSaveButton.setOnClickListener { saveChanges() }
|
||||
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
|
||||
binding.filterDeleteButton.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter()
|
||||
}
|
||||
}
|
||||
binding.filterDeleteButton.visible(originalFilter != null)
|
||||
|
||||
for (switch in contextSwitches.keys) {
|
||||
|
@ -259,6 +265,9 @@ class EditFilterActivity : BaseActivity() {
|
|||
lifecycleScope.launch {
|
||||
if (viewModel.saveChanges(this@EditFilterActivity)) {
|
||||
finish()
|
||||
// Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter
|
||||
val affectedContexts = viewModel.contexts.value.map { it.kind }.union(originalFilter?.context ?: listOf()).distinct()
|
||||
eventHub.dispatch(FilterUpdatedEvent(affectedContexts))
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
@ -273,7 +282,7 @@ class EditFilterActivity : BaseActivity() {
|
|||
finish()
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
|
|
|
@ -8,9 +8,9 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterKeyword
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
|
||||
|
@ -108,7 +108,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
},
|
||||
{ throwable ->
|
||||
return (
|
||||
throwable is HttpException && throwable.code() == 404 &&
|
||||
throwable.isHttpNotFound() &&
|
||||
// Endpoint not found, fall back to v1 api
|
||||
createFilterV1(contexts, expiresInSeconds)
|
||||
)
|
||||
|
@ -141,7 +141,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
return results.none { it.isFailure }
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
// Endpoint not found, fall back to v1 api
|
||||
if (updateFilterV1(contexts, expiresInSeconds)) {
|
||||
return true
|
||||
|
|
|
@ -15,24 +15,15 @@
|
|||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
import android.app.Activity
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.util.await
|
||||
|
||||
/** Show load state and retry options when loading notifications */
|
||||
class NotificationsLoadStateAdapter(
|
||||
private val retry: () -> Unit
|
||||
) : LoadStateAdapter<NotificationsLoadStateViewHolder>() {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): NotificationsLoadStateViewHolder {
|
||||
return NotificationsLoadStateViewHolder.create(parent, retry)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) {
|
||||
holder.bind(loadState)
|
||||
}
|
||||
}
|
||||
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
|
||||
.setCancelable(true)
|
||||
.create()
|
||||
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)
|
|
@ -1,5 +1,6 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.content.DialogInterface.BUTTON_POSITIVE
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
|
@ -60,18 +61,19 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
when (state.loadingState) {
|
||||
FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide()
|
||||
FiltersViewModel.LoadingState.ERROR_NETWORK -> {
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
|
||||
loadFilters()
|
||||
}
|
||||
binding.messageView.show()
|
||||
}
|
||||
FiltersViewModel.LoadingState.ERROR_OTHER -> {
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
|
||||
loadFilters()
|
||||
}
|
||||
binding.messageView.show()
|
||||
}
|
||||
FiltersViewModel.LoadingState.LOADED -> {
|
||||
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
|
||||
if (state.filters.isEmpty()) {
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
|
@ -81,7 +83,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
binding.messageView.show()
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -104,8 +105,12 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
}
|
||||
|
||||
override fun deleteFilter(filter: Filter) {
|
||||
lifecycleScope.launch {
|
||||
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) {
|
||||
viewModel.deleteFilter(filter, binding.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateFilter(updatedFilter: Filter) {
|
||||
launchEditFilterActivity(updatedFilter)
|
||||
|
|
|
@ -9,10 +9,10 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
class FiltersViewModel @Inject constructor(
|
||||
|
@ -38,14 +38,13 @@ class FiltersViewModel @Inject constructor(
|
|||
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
api.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
|
||||
},
|
||||
{ throwable ->
|
||||
{ _ ->
|
||||
// TODO log errors (also below)
|
||||
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
|
||||
}
|
||||
)
|
||||
|
@ -68,7 +67,7 @@ class FiltersViewModel @Inject constructor(
|
|||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
|
||||
|
|
|
@ -28,5 +28,6 @@ data class InstanceInfo(
|
|||
val maxMediaAttachments: Int,
|
||||
val maxFields: Int,
|
||||
val maxFieldNameLength: Int?,
|
||||
val maxFieldValueLength: Int?
|
||||
val maxFieldValueLength: Int?,
|
||||
val version: String?
|
||||
)
|
||||
|
|
|
@ -25,6 +25,7 @@ import com.keylesspalace.tusky.db.EmojisEntity
|
|||
import com.keylesspalace.tusky.db.InstanceInfoEntity
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
@ -62,6 +63,41 @@ class InstanceInfoRepository @Inject constructor(
|
|||
*/
|
||||
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
|
||||
api.getInstance()
|
||||
.fold(
|
||||
{ instance ->
|
||||
val instanceEntity = InstanceInfoEntity(
|
||||
instance = instanceName,
|
||||
maximumTootCharacters = instance.configuration.statuses?.maxCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
maxPollOptions = instance.configuration.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
maxPollOptionLength = instance.configuration.polls?.maxCharactersPerOption ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
minPollDuration = instance.configuration.polls?.minExpirationSeconds ?: DEFAULT_MIN_POLL_DURATION,
|
||||
maxPollDuration = instance.configuration.polls?.maxExpirationSeconds ?: DEFAULT_MAX_POLL_DURATION,
|
||||
charactersReservedPerUrl = instance.configuration.statuses?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||
version = instance.version,
|
||||
videoSizeLimit = instance.configuration.mediaAttachments?.videoSizeLimitBytes?.toInt() ?: DEFAULT_VIDEO_SIZE_LIMIT,
|
||||
imageSizeLimit = instance.configuration.mediaAttachments?.imageSizeLimitBytes?.toInt() ?: DEFAULT_IMAGE_SIZE_LIMIT,
|
||||
imageMatrixLimit = instance.configuration.mediaAttachments?.imagePixelCountLimit?.toInt() ?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
||||
maxMediaAttachments = instance.configuration.statuses?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
||||
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength,
|
||||
)
|
||||
dao.upsert(instanceEntity)
|
||||
instanceEntity
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable.isHttpNotFound()) {
|
||||
getInstanceInfoV1()
|
||||
} else {
|
||||
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
|
||||
getCachedInstanceInfoEntity()
|
||||
}
|
||||
}
|
||||
).toInstanceInfo()
|
||||
}
|
||||
|
||||
private suspend fun getInstanceInfoV1(): InstanceInfoEntity? = withContext(Dispatchers.IO) {
|
||||
api.getInstanceV1()
|
||||
.fold(
|
||||
{ instance ->
|
||||
val instanceEntity = InstanceInfoEntity(
|
||||
|
@ -79,7 +115,7 @@ class InstanceInfoRepository @Inject constructor(
|
|||
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments,
|
||||
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
|
||||
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength,
|
||||
)
|
||||
dao.upsert(instanceEntity)
|
||||
instanceEntity
|
||||
|
@ -88,7 +124,7 @@ class InstanceInfoRepository @Inject constructor(
|
|||
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
|
||||
getCachedInstanceInfoEntity()
|
||||
}
|
||||
).toInstanceInfo()
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getCachedInstanceInfoEntity(): InstanceInfoEntity? =
|
||||
|
@ -149,6 +185,7 @@ class InstanceInfoRepository @Inject constructor(
|
|||
maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
|
||||
maxFieldNameLength = this?.maxFieldNameLength,
|
||||
maxFieldValueLength = this?.maxFieldValueLength,
|
||||
version = this?.version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.instancemute.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
|
||||
import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
|
||||
class DomainMutesAdapter(
|
||||
private val actionListener: InstanceActionListener
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemMutedDomainBinding>>() {
|
||||
|
||||
var instances: MutableList<String> = mutableListOf()
|
||||
var bottomLoading: Boolean = false
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemMutedDomainBinding> {
|
||||
val binding = ItemMutedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemMutedDomainBinding>, position: Int) {
|
||||
val instance = instances[position]
|
||||
|
||||
holder.binding.mutedDomain.text = instance
|
||||
holder.binding.mutedDomainUnmute.setOnClickListener {
|
||||
actionListener.mute(false, instance, holder.bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
var count = instances.size
|
||||
if (bottomLoading) {
|
||||
++count
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
fun addItems(newInstances: List<String>) {
|
||||
val end = instances.size
|
||||
instances.addAll(newInstances)
|
||||
notifyItemRangeInserted(end, instances.size)
|
||||
}
|
||||
|
||||
fun addItem(instance: String) {
|
||||
instances.add(instance)
|
||||
notifyItemInserted(instances.size)
|
||||
}
|
||||
|
||||
fun removeItem(position: Int) {
|
||||
if (position >= 0 && position < instances.size) {
|
||||
instances.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,158 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.instancemute.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
|
||||
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
|
||||
import com.keylesspalace.tusky.databinding.FragmentInstanceListBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
|
||||
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
private val binding by viewBinding(FragmentInstanceListBinding::bind)
|
||||
|
||||
private var fetching = false
|
||||
private var bottomId: String? = null
|
||||
private var adapter = DomainMutesAdapter(this)
|
||||
private lateinit var scrollListener: EndlessOnScrollListener
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
|
||||
scrollListener = object : EndlessOnScrollListener(layoutManager) {
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||
if (bottomId != null) {
|
||||
fetchInstances(bottomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.recyclerView.addOnScrollListener(scrollListener)
|
||||
fetchInstances()
|
||||
}
|
||||
|
||||
override fun mute(mute: Boolean, instance: String, position: Int) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
if (mute) {
|
||||
api.blockDomain(instance).fold({
|
||||
adapter.addItem(instance)
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error muting domain $instance", e)
|
||||
})
|
||||
} else {
|
||||
api.unblockDomain(instance).fold({
|
||||
adapter.removeItem(position)
|
||||
Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_undo) {
|
||||
mute(true, instance, position)
|
||||
}
|
||||
.show()
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error unmuting domain $instance", e)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchInstances(id: String? = null) {
|
||||
if (fetching) {
|
||||
return
|
||||
}
|
||||
fetching = true
|
||||
binding.instanceProgressBar.show()
|
||||
|
||||
if (id != null) {
|
||||
binding.recyclerView.post { adapter.bottomLoading = true }
|
||||
}
|
||||
|
||||
api.domainBlocks(id, bottomId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{ response ->
|
||||
val instances = response.body()
|
||||
|
||||
if (response.isSuccessful && instances != null) {
|
||||
onFetchInstancesSuccess(instances, response.headers()["Link"])
|
||||
} else {
|
||||
onFetchInstancesFailure(Exception(response.message()))
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
onFetchInstancesFailure(throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) {
|
||||
adapter.bottomLoading = false
|
||||
binding.instanceProgressBar.hide()
|
||||
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
val next = HttpHeaderLink.findByRelationType(links, "next")
|
||||
val fromId = next?.uri?.getQueryParameter("max_id")
|
||||
adapter.addItems(instances)
|
||||
bottomId = fromId
|
||||
fetching = false
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFetchInstancesFailure(throwable: Throwable) {
|
||||
fetching = false
|
||||
binding.instanceProgressBar.hide()
|
||||
Log.e(TAG, "Fetch failure", throwable)
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(throwable) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InstanceList" // logging tag
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.instancemute.interfaces
|
||||
|
||||
interface InstanceActionListener {
|
||||
fun mute(mute: Boolean, instance: String, position: Int)
|
||||
}
|
|
@ -99,7 +99,7 @@ sealed class LoginResult : Parcelable {
|
|||
data class Err(val errorMessage: String) : LoginResult()
|
||||
|
||||
@Parcelize
|
||||
object Cancel : LoginResult()
|
||||
data object Cancel : LoginResult()
|
||||
}
|
||||
|
||||
/** Activity to do Oauth process using WebView. */
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
@ -36,11 +37,25 @@ class LoginWebViewViewModel @Inject constructor(
|
|||
if (this.domain == null) {
|
||||
this.domain = domain
|
||||
viewModelScope.launch {
|
||||
api.getInstance(domain).fold({ instance ->
|
||||
api.getInstance().fold(
|
||||
{ instance ->
|
||||
instanceRules.value = instance.rules.map { rule -> rule.text }
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable.isHttpNotFound()) {
|
||||
api.getInstanceV1(domain).fold(
|
||||
{ instance ->
|
||||
instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty()
|
||||
}, { throwable ->
|
||||
},
|
||||
{ throwable ->
|
||||
Log.w("LoginWebViewViewModel", "failed to load instance info", throwable)
|
||||
})
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.w("LoginWebViewViewModel", "failed to load instance info", throwable)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,111 +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.components.notifications
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowViewHolder(
|
||||
private val binding: ItemFollowBinding,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val linkListener: LinkListener
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_42dp
|
||||
)
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setMessage(
|
||||
viewData.account,
|
||||
viewData.type === Notification.Type.SIGN_UP,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis
|
||||
)
|
||||
setupButtons(notificationActionListener, viewData.account.id)
|
||||
}
|
||||
|
||||
private fun setMessage(
|
||||
account: TimelineAccount,
|
||||
isSignUp: Boolean,
|
||||
animateAvatars: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val context = binding.notificationText.context
|
||||
val format =
|
||||
context.getString(
|
||||
if (isSignUp) {
|
||||
R.string.notification_sign_up_format
|
||||
} else {
|
||||
R.string.notification_follow_format
|
||||
}
|
||||
)
|
||||
val wrappedDisplayName = account.name.unicodeWrap()
|
||||
val wholeMessage = String.format(format, wrappedDisplayName)
|
||||
val emojifiedMessage =
|
||||
wholeMessage.emojify(
|
||||
account.emojis,
|
||||
binding.notificationText,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationText.text = emojifiedMessage
|
||||
val username = context.getString(R.string.post_username_format, account.username)
|
||||
binding.notificationUsername.text = username
|
||||
val emojifiedDisplayName = wrappedDisplayName.emojify(
|
||||
account.emojis,
|
||||
binding.notificationUsername,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationDisplayName.text = emojifiedDisplayName
|
||||
loadAvatar(
|
||||
account.avatar,
|
||||
binding.notificationAvatar,
|
||||
avatarRadius42dp,
|
||||
animateAvatars
|
||||
)
|
||||
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
|
||||
account.emojis,
|
||||
binding.notificationAccountNote,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener)
|
||||
}
|
||||
|
||||
private fun setupButtons(listener: NotificationActionListener, accountId: String) {
|
||||
binding.root.setOnClickListener { listener.onViewAccount(accountId) }
|
||||
}
|
||||
}
|
|
@ -4,18 +4,38 @@ import android.app.NotificationManager
|
|||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Marker
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import com.keylesspalace.tusky.util.isLessThan
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/** Models next/prev links from the "Links" header in an API response */
|
||||
data class Links(val next: String?, val prev: String?) {
|
||||
companion object {
|
||||
fun from(linkHeader: String?): Links {
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
return Links(
|
||||
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
|
||||
"max_id"
|
||||
),
|
||||
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
|
||||
"min_id"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Mastodon notifications and show Android notifications, with summaries, for them.
|
||||
*
|
||||
|
@ -28,7 +48,8 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||
class NotificationFetcher @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val context: Context
|
||||
private val context: Context,
|
||||
private val eventHub: EventHub
|
||||
) {
|
||||
suspend fun fetchAndShow() {
|
||||
for (account in accountManager.getAllAccountsOrderedByActive()) {
|
||||
|
@ -42,6 +63,10 @@ class NotificationFetcher @Inject constructor(
|
|||
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
|
||||
.toMutableList()
|
||||
|
||||
// TODO do this before filter above? But one could argue that (for example) a tab badge is also a notification
|
||||
// (and should therefore adhere to the notification config).
|
||||
eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications))
|
||||
|
||||
// There's a maximum limit on the number of notifications an Android app
|
||||
// can display. If the total number of notifications (current notifications,
|
||||
// plus new ones) exceeds this then some newer notifications will be dropped.
|
||||
|
@ -65,24 +90,30 @@ class NotificationFetcher @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val notificationsByType = notifications.groupBy { it.type }
|
||||
|
||||
// Make and send the new notifications
|
||||
// TODO: Use the batch notification API available in NotificationManagerCompat
|
||||
// 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
|
||||
// when it is released.
|
||||
notifications.forEachIndexed { index, notification ->
|
||||
|
||||
notificationsByType.forEach { notificationsGroup ->
|
||||
notificationsGroup.value.forEach { notification ->
|
||||
val androidNotification = NotificationHelper.make(
|
||||
context,
|
||||
notificationManager,
|
||||
notification,
|
||||
account,
|
||||
index == 0
|
||||
notificationsGroup.value.size == 1
|
||||
)
|
||||
notificationManager.notify(notification.id, account.id.toInt(), androidNotification)
|
||||
|
||||
// Android will rate limit / drop notifications if they're posted too
|
||||
// quickly. There is no indication to the user that this happened.
|
||||
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationHelper.updateSummaryNotifications(
|
||||
context,
|
||||
|
|
|
@ -85,13 +85,6 @@ public class NotificationHelper {
|
|||
/** Dynamic notification IDs start here */
|
||||
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
|
||||
|
||||
/**
|
||||
* constants used in Intents
|
||||
*/
|
||||
public static final String ACCOUNT_ID = "account_id";
|
||||
|
||||
public static final String TYPE = APPLICATION_ID + ".notification.type";
|
||||
|
||||
private static final String TAG = "NotificationHelper";
|
||||
|
||||
public static final String REPLY_ACTION = "REPLY_ACTION";
|
||||
|
@ -156,7 +149,7 @@ public class NotificationHelper {
|
|||
* @return the new notification
|
||||
*/
|
||||
@NonNull
|
||||
public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) {
|
||||
public static android.app.Notification make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account, boolean isOnlyOneInGroup) {
|
||||
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
|
||||
String mastodonNotificationId = body.getId();
|
||||
int accountId = (int) account.getId();
|
||||
|
@ -208,8 +201,7 @@ public class NotificationHelper {
|
|||
builder.setLargeIcon(accountAvatar);
|
||||
|
||||
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
|
||||
if (body.getType() == Notification.Type.MENTION
|
||||
&& android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (body.getType() == Notification.Type.MENTION) {
|
||||
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
|
||||
.setLabel(context.getString(R.string.label_quick_reply))
|
||||
.build();
|
||||
|
@ -245,11 +237,11 @@ public class NotificationHelper {
|
|||
Bundle extras = new Bundle();
|
||||
// Add the sending account's name, so it can be used when summarising this notification
|
||||
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
|
||||
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString());
|
||||
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().name());
|
||||
builder.addExtras(extras);
|
||||
|
||||
// Only alert for the first notification of a batch to avoid multiple alerts at once
|
||||
if(!isFirstOfBatch) {
|
||||
if(!isOnlyOneInGroup) {
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
|
||||
}
|
||||
|
||||
|
@ -278,14 +270,14 @@ public class NotificationHelper {
|
|||
* @param notificationManager the system's NotificationManager
|
||||
* @param account the account for which the notification should be shown
|
||||
*/
|
||||
public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) {
|
||||
public static void updateSummaryNotifications(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account) {
|
||||
// Map from the channel ID to a list of notifications in that channel. Those are the
|
||||
// notifications that will be summarised.
|
||||
Map<String, List<StatusBarNotification>> channelGroups = new HashMap<>();
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
// Initialise the map with all channel IDs.
|
||||
for (Notification.Type ty : Notification.Type.values()) {
|
||||
for (Notification.Type ty : Notification.Type.getEntries()) {
|
||||
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
|
||||
}
|
||||
|
||||
|
@ -325,11 +317,11 @@ public class NotificationHelper {
|
|||
// Create a notification that summarises the other notifications in this group
|
||||
|
||||
// All notifications in this group have the same type, so get it from the first.
|
||||
String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE);
|
||||
String typeName = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE, Notification.Type.UNKNOWN.name());
|
||||
Notification.Type notificationType = Notification.Type.valueOf(typeName);
|
||||
|
||||
Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType);
|
||||
|
||||
Intent summaryResultIntent = new Intent(context, MainActivity.class);
|
||||
summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId);
|
||||
summaryResultIntent.putExtra(TYPE, notificationType);
|
||||
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
|
||||
summaryStackBuilder.addParentStack(MainActivity.class);
|
||||
summaryStackBuilder.addNextIntent(summaryResultIntent);
|
||||
|
@ -373,10 +365,8 @@ public class NotificationHelper {
|
|||
|
||||
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
|
||||
|
||||
// we have to switch account here
|
||||
Intent eventResultIntent = new Intent(context, MainActivity.class);
|
||||
eventResultIntent.putExtra(ACCOUNT_ID, account.getId());
|
||||
eventResultIntent.putExtra(TYPE, body.getType().name());
|
||||
Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType());
|
||||
|
||||
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
|
||||
eventStackBuilder.addParentStack(MainActivity.class);
|
||||
eventStackBuilder.addNextIntent(eventResultIntent);
|
||||
|
@ -464,12 +454,7 @@ public class NotificationHelper {
|
|||
composeOptions.setLanguage(actionableStatus.getLanguage());
|
||||
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
|
||||
|
||||
Intent composeIntent = ComposeActivity.startIntent(
|
||||
context,
|
||||
composeOptions,
|
||||
notificationId,
|
||||
account.getId()
|
||||
);
|
||||
Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId());
|
||||
|
||||
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
|
@ -624,7 +609,7 @@ public class NotificationHelper {
|
|||
|
||||
}
|
||||
|
||||
public static void enablePullNotifications(Context context) {
|
||||
public static void enablePullNotifications(@NonNull Context context) {
|
||||
WorkManager workManager = WorkManager.getInstance(context);
|
||||
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
|
||||
|
||||
|
@ -652,7 +637,7 @@ public class NotificationHelper {
|
|||
Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval");
|
||||
}
|
||||
|
||||
public static void disablePullNotifications(Context context) {
|
||||
public static void disablePullNotifications(@NonNull Context context) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
|
||||
Log.d(TAG, "disabled notification checks");
|
||||
}
|
||||
|
@ -668,7 +653,7 @@ public class NotificationHelper {
|
|||
}
|
||||
}
|
||||
|
||||
public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) {
|
||||
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) {
|
||||
return filterNotification(notificationManager, account, notification.getType());
|
||||
}
|
||||
|
||||
|
@ -874,7 +859,7 @@ public class NotificationHelper {
|
|||
if (mutable) {
|
||||
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
|
||||
} else {
|
||||
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,697 +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.components.notifications
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
|
||||
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationsFragment :
|
||||
SFragment(),
|
||||
StatusActionListener,
|
||||
NotificationActionListener,
|
||||
AccountActionListener,
|
||||
OnRefreshListener,
|
||||
MenuProvider,
|
||||
Injectable,
|
||||
ReselectableFragment {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: NotificationsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
|
||||
|
||||
private lateinit var adapter: NotificationsPagingAdapter
|
||||
|
||||
private lateinit var layoutManager: LinearLayoutManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = NotificationsPagingAdapter(
|
||||
notificationDiffCallback,
|
||||
accountId = viewModel.account.accountId,
|
||||
statusActionListener = this,
|
||||
notificationActionListener = this,
|
||||
accountActionListener = this,
|
||||
statusDisplayOptions = viewModel.statusDisplayOptions.value
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
|
||||
}
|
||||
|
||||
private fun updateFilterVisibility(showFilter: Boolean) {
|
||||
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
|
||||
if (showFilter) {
|
||||
binding.appBarOptions.setExpanded(true, false)
|
||||
binding.appBarOptions.visibility = View.VISIBLE
|
||||
// Set content behaviour to hide filter on scroll
|
||||
params.behavior = ScrollingViewBehavior()
|
||||
} else {
|
||||
binding.appBarOptions.setExpanded(false, false)
|
||||
binding.appBarOptions.visibility = View.GONE
|
||||
// Clear behaviour to hide app bar
|
||||
params.behavior = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmClearNotifications() {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.notification_clear_text)
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
// Setup the SwipeRefreshLayout.
|
||||
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||
|
||||
// Setup the RecyclerView.
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||
ListStatusAccessibilityDelegate(
|
||||
binding.recyclerView,
|
||||
this
|
||||
) { pos: Int ->
|
||||
val notification = adapter.snapshot().getOrNull(pos)
|
||||
// We support replies only for now
|
||||
if (notification is NotificationViewData) {
|
||||
notification.statusViewData
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
)
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
context,
|
||||
DividerItemDecoration.VERTICAL
|
||||
)
|
||||
)
|
||||
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
val actionButton = (activity as ActionButtonActivity).actionButton
|
||||
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
actionButton?.let { fab ->
|
||||
if (!viewModel.uiState.value.showFabWhileScrolling) {
|
||||
if (dy > 0 && fab.isShown) {
|
||||
fab.hide() // Hide when scrolling down
|
||||
} else if (dy < 0 && !fab.isShown) {
|
||||
fab.show() // Show when scrolling up
|
||||
}
|
||||
} else if (!fab.isShown) {
|
||||
fab.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SyntheticAccessor")
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
newState != SCROLL_STATE_IDLE && return
|
||||
|
||||
// Save the ID of the first notification visible in the list, so the user's
|
||||
// reading position is always restorable.
|
||||
layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
|
||||
adapter.snapshot().getOrNull(position)?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
|
||||
header = NotificationsLoadStateAdapter { adapter.retry() },
|
||||
footer = NotificationsLoadStateAdapter { adapter.retry() }
|
||||
)
|
||||
|
||||
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
|
||||
binding.buttonFilter.setOnClickListener { showFilterDialog() }
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
|
||||
false
|
||||
|
||||
// Signal the user that a refresh has loaded new items above their current position
|
||||
// by scrolling up slightly to disclose the new content
|
||||
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||
binding.recyclerView.post {
|
||||
if (getView() != null) {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// update post timestamps
|
||||
val updateTimestampFlow = flow {
|
||||
while (true) {
|
||||
delay(60000)
|
||||
emit(Unit)
|
||||
}
|
||||
}.onEach {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch {
|
||||
viewModel.pagingData.collectLatest { pagingData ->
|
||||
Log.d(TAG, "Submitting data to adapter")
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
// Show errors from the view model as snack bars.
|
||||
//
|
||||
// Errors are shown:
|
||||
// - Indefinitely, so the user has a chance to read and understand
|
||||
// the message
|
||||
// - With a max of 5 text lines, to allow space for longer errors.
|
||||
// E.g., on a typical device, an error message like "Bookmarking
|
||||
// post failed: Unable to resolve host 'mastodon.social': No
|
||||
// address associated with hostname" is 3 lines.
|
||||
// - With a "Retry" option if the error included a UiAction to retry.
|
||||
launch {
|
||||
viewModel.uiError.collect { error ->
|
||||
Log.d(TAG, error.toString())
|
||||
val message = getString(
|
||||
error.message,
|
||||
error.throwable.localizedMessage
|
||||
?: getString(R.string.ui_error_unknown)
|
||||
)
|
||||
val snackbar = Snackbar.make(
|
||||
// Without this the FAB will not move out of the way
|
||||
(activity as ActionButtonActivity).actionButton ?: binding.root,
|
||||
message,
|
||||
Snackbar.LENGTH_INDEFINITE
|
||||
).setTextMaxLines(5)
|
||||
error.action?.let { action ->
|
||||
snackbar.setAction(R.string.action_retry) {
|
||||
viewModel.accept(action)
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
|
||||
// The status view has pre-emptively updated its state to show
|
||||
// that the action succeeded. Since it hasn't, re-bind the view
|
||||
// to show the correct data.
|
||||
error.action?.let { action ->
|
||||
action is StatusAction || return@let
|
||||
|
||||
val position = adapter.snapshot().indexOfFirst {
|
||||
it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id
|
||||
}
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show successful notification action as brief snackbars, so the
|
||||
// user is clear the action has happened.
|
||||
launch {
|
||||
viewModel.uiSuccess
|
||||
.filterIsInstance<NotificationActionSuccess>()
|
||||
.collect {
|
||||
Snackbar.make(
|
||||
(activity as ActionButtonActivity).actionButton ?: binding.root,
|
||||
getString(it.msg),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
when (it) {
|
||||
// The follow request is no longer valid, refresh the adapter to
|
||||
// remove it.
|
||||
is NotificationActionSuccess.AcceptFollowRequest,
|
||||
is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update adapter data when status actions are successful, and re-bind to update
|
||||
// the UI.
|
||||
launch {
|
||||
viewModel.uiSuccess
|
||||
.filterIsInstance<StatusActionSuccess>()
|
||||
.collect {
|
||||
val indexedViewData = adapter.snapshot()
|
||||
.withIndex()
|
||||
.firstOrNull { notificationViewData ->
|
||||
notificationViewData.value?.statusViewData?.status?.id ==
|
||||
it.action.statusViewData.id
|
||||
} ?: return@collect
|
||||
|
||||
val statusViewData =
|
||||
indexedViewData.value?.statusViewData ?: return@collect
|
||||
|
||||
val status = when (it) {
|
||||
is StatusActionSuccess.Bookmark ->
|
||||
statusViewData.status.copy(bookmarked = it.action.state)
|
||||
is StatusActionSuccess.Favourite ->
|
||||
statusViewData.status.copy(favourited = it.action.state)
|
||||
is StatusActionSuccess.Reblog ->
|
||||
statusViewData.status.copy(reblogged = it.action.state)
|
||||
is StatusActionSuccess.VoteInPoll ->
|
||||
statusViewData.status.copy(
|
||||
poll = it.action.poll.votedCopy(it.action.choices)
|
||||
)
|
||||
}
|
||||
indexedViewData.value?.statusViewData = statusViewData.copy(
|
||||
status = status
|
||||
)
|
||||
|
||||
adapter.notifyItemChanged(indexedViewData.index)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh adapter on mutes and blocks
|
||||
launch {
|
||||
viewModel.uiSuccess.collectLatest {
|
||||
when (it) {
|
||||
is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation ->
|
||||
adapter.refresh()
|
||||
else -> { /* nothing to do */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update filter option visibility from uiState
|
||||
launch {
|
||||
viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) }
|
||||
}
|
||||
|
||||
// Update status display from statusDisplayOptions. If the new options request
|
||||
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
|
||||
launch {
|
||||
viewModel.statusDisplayOptions
|
||||
.collectLatest {
|
||||
// NOTE this this also triggered (emitted?) on resume.
|
||||
|
||||
adapter.statusDisplayOptions = it
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, null)
|
||||
|
||||
if (!it.useAbsoluteTime) {
|
||||
updateTimestampFlow.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the UI from the loadState
|
||||
adapter.loadStateFlow
|
||||
.distinctUntilChangedBy { it.refresh }
|
||||
.collect { loadState ->
|
||||
binding.recyclerView.isVisible = true
|
||||
binding.progressBar.isVisible = loadState.refresh is LoadState.Loading &&
|
||||
!binding.swipeRefreshLayout.isRefreshing
|
||||
binding.swipeRefreshLayout.isRefreshing =
|
||||
loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible
|
||||
|
||||
binding.statusView.isVisible = false
|
||||
if (loadState.refresh is LoadState.NotLoading) {
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty
|
||||
)
|
||||
binding.recyclerView.isVisible = false
|
||||
binding.statusView.isVisible = true
|
||||
} else {
|
||||
binding.statusView.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
if (loadState.refresh is LoadState.Error) {
|
||||
when ((loadState.refresh as LoadState.Error).error) {
|
||||
is IOException -> {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network
|
||||
) { adapter.retry() }
|
||||
}
|
||||
else -> {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic
|
||||
) { adapter.retry() }
|
||||
}
|
||||
}
|
||||
binding.recyclerView.isVisible = false
|
||||
binding.statusView.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.fragment_notifications, menu)
|
||||
menu.findItem(R.id.action_refresh)?.apply {
|
||||
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
|
||||
sizeDp = 20
|
||||
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return when (menuItem.itemId) {
|
||||
R.id.action_refresh -> {
|
||||
binding.swipeRefreshLayout.isRefreshing = true
|
||||
onRefresh()
|
||||
true
|
||||
}
|
||||
R.id.load_newest -> {
|
||||
viewModel.accept(InfallibleUiAction.LoadNewest)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
binding.progressBar.isVisible = false
|
||||
adapter.refresh()
|
||||
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
// Save the ID of the first notification visible in the list
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
if (position >= 0) {
|
||||
adapter.snapshot().getOrNull(position)?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.reply(status)
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
viewModel.accept(StatusAction.Reblog(reblog, statusViewData))
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
viewModel.accept(StatusAction.Favourite(favourite, statusViewData))
|
||||
}
|
||||
|
||||
override fun onQuote(position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.quote(status)
|
||||
}
|
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData))
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
||||
val statusViewData = adapter.peek(position)?.statusViewData ?: return
|
||||
val poll = statusViewData.status.poll ?: return
|
||||
viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData))
|
||||
}
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.more(status, view, position)
|
||||
}
|
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.viewMedia(attachmentIndex, list(status), view)
|
||||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
val status = adapter.peek(position)?.statusViewData?.status ?: return
|
||||
super.viewThread(status.actionableId, status.actionableStatus.url)
|
||||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
val account = adapter.peek(position)?.account!!
|
||||
onViewAccount(account.id)
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
val notificationViewData = adapter.snapshot()[position] ?: return
|
||||
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
|
||||
isExpanded = expanded
|
||||
)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
||||
val notificationViewData = adapter.snapshot()[position] ?: return
|
||||
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
|
||||
isShowingContent = isShowing
|
||||
)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onLoadMore(position: Int) {
|
||||
// Empty -- this fragment doesn't show placeholders
|
||||
}
|
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
val notificationViewData = adapter.snapshot()[position] ?: return
|
||||
notificationViewData.statusViewData = notificationViewData.statusViewData?.copy(
|
||||
isCollapsed = isCollapsed
|
||||
)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
||||
onContentCollapsedChange(isCollapsed, position)
|
||||
}
|
||||
|
||||
override fun clearWarningAction(position: Int) {
|
||||
}
|
||||
|
||||
private fun clearNotifications() {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.progressBar.isVisible = false
|
||||
viewModel.accept(FallibleUiAction.ClearNotifications)
|
||||
}
|
||||
|
||||
private fun showFilterDialog() {
|
||||
FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter ->
|
||||
if (viewModel.uiState.value.activeFilter != filter) {
|
||||
viewModel.accept(InfallibleUiAction.ApplyFilter(filter))
|
||||
}
|
||||
}
|
||||
.show(parentFragmentManager, "dialogFilter")
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
super.viewTag(tag)
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
super.viewAccount(id)
|
||||
}
|
||||
|
||||
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
override fun onBlock(block: Boolean, id: String, position: Int) {
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
|
||||
if (accept) {
|
||||
viewModel.accept(NotificationAction.AcceptFollowRequest(accountId))
|
||||
} else {
|
||||
viewModel.accept(NotificationAction.RejectFollowRequest(accountId))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewThreadForStatus(status: Status) {
|
||||
super.viewThread(status.actionableId, status.actionableStatus.url)
|
||||
}
|
||||
|
||||
override fun onViewReport(reportId: String) {
|
||||
requireContext().openLink(
|
||||
"https://${viewModel.account.domain}/admin/reports/$reportId"
|
||||
)
|
||||
}
|
||||
|
||||
public override fun removeItem(position: Int) {
|
||||
// Empty -- this fragment doesn't remove items
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
binding.appBarOptions.setExpanded(true, false)
|
||||
layoutManager.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsFragment"
|
||||
fun newInstance() = NotificationsFragment()
|
||||
|
||||
private val notificationDiffCallback: DiffUtil.ItemCallback<NotificationViewData> =
|
||||
object : DiffUtil.ItemCallback<NotificationViewData>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: NotificationViewData,
|
||||
newItem: NotificationViewData
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: NotificationViewData,
|
||||
newItem: NotificationViewData
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: NotificationViewData,
|
||||
newItem: NotificationViewData
|
||||
): Any? {
|
||||
return if (oldItem == newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
} else {
|
||||
// If items are different - update a whole view holder
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDialogFragment(
|
||||
private val activeFilter: Set<Notification.Type>,
|
||||
private val listener: ((filter: Set<Notification.Type>) -> Unit)
|
||||
) : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
|
||||
val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray()
|
||||
val checkedItems = Notification.Type.visibleTypes.map {
|
||||
!activeFilter.contains(it)
|
||||
}.toBooleanArray()
|
||||
|
||||
val builder = AlertDialog.Builder(context)
|
||||
.setTitle(R.string.notifications_apply_filter)
|
||||
.setMultiChoiceItems(items, checkedItems) { _, which, isChecked ->
|
||||
checkedItems[which] = isChecked
|
||||
}
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val excludes: MutableSet<Notification.Type> = HashSet()
|
||||
for (i in Notification.Type.visibleTypes.indices) {
|
||||
if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i])
|
||||
}
|
||||
listener(excludes)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
return builder.create()
|
||||
}
|
||||
}
|
|
@ -1,73 +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.components.notifications
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* Display the header/footer loading state to the user.
|
||||
*
|
||||
* Either:
|
||||
*
|
||||
* 1. A page is being loaded, display a progress view, or
|
||||
* 2. An error occurred, display an error message with a "retry" button
|
||||
*
|
||||
* @param retry function to invoke if the user clicks the "retry" button
|
||||
*/
|
||||
class NotificationsLoadStateViewHolder(
|
||||
private val binding: ItemNotificationsLoadStateFooterViewBinding,
|
||||
retry: () -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
binding.retryButton.setOnClickListener { retry.invoke() }
|
||||
}
|
||||
|
||||
fun bind(loadState: LoadState) {
|
||||
if (loadState is LoadState.Error) {
|
||||
val ctx = binding.root.context
|
||||
binding.errorMsg.text = when (loadState.error) {
|
||||
is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception)
|
||||
// Other exceptions to consider:
|
||||
// - UnknownHostException, default text is:
|
||||
// Unable to resolve "%s": No address associated with hostname
|
||||
else -> loadState.error.localizedMessage
|
||||
}
|
||||
}
|
||||
binding.progressBar.isVisible = loadState is LoadState.Loading
|
||||
binding.retryButton.isVisible = loadState is LoadState.Error
|
||||
binding.errorMsg.isVisible = loadState is LoadState.Error
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder {
|
||||
val binding = ItemNotificationsLoadStateFooterViewBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return NotificationsLoadStateViewHolder(binding, retry)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,207 +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.components.notifications
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
|
||||
import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
||||
import com.keylesspalace.tusky.databinding.SimpleListItem1Binding
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
/** How to present the notification in the UI */
|
||||
enum class NotificationViewKind {
|
||||
/** View as the original status */
|
||||
STATUS,
|
||||
|
||||
/** View as the original status, with the interaction type above */
|
||||
NOTIFICATION,
|
||||
FOLLOW,
|
||||
FOLLOW_REQUEST,
|
||||
REPORT,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun from(kind: Notification.Type?): NotificationViewKind {
|
||||
return when (kind) {
|
||||
Notification.Type.MENTION,
|
||||
Notification.Type.POLL,
|
||||
Notification.Type.UNKNOWN -> STATUS
|
||||
Notification.Type.FAVOURITE,
|
||||
Notification.Type.REBLOG,
|
||||
Notification.Type.STATUS,
|
||||
Notification.Type.UPDATE -> NOTIFICATION
|
||||
Notification.Type.FOLLOW,
|
||||
Notification.Type.SIGN_UP -> FOLLOW
|
||||
Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST
|
||||
Notification.Type.REPORT -> REPORT
|
||||
null -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface NotificationActionListener {
|
||||
fun onViewAccount(id: String)
|
||||
fun onViewThreadForStatus(status: Status)
|
||||
fun onViewReport(reportId: String)
|
||||
|
||||
/**
|
||||
* Called when the status has a content warning and the visibility of the content behind
|
||||
* the warning is being changed.
|
||||
*
|
||||
* @param expanded the desired state of the content behind the content warning
|
||||
* @param position the adapter position of the view
|
||||
*
|
||||
*/
|
||||
fun onExpandedChange(expanded: Boolean, position: Int)
|
||||
|
||||
/**
|
||||
* Called when the status [android.widget.ToggleButton] responsible for collapsing long
|
||||
* status content is interacted with.
|
||||
*
|
||||
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
|
||||
* @param position The position of the status in the list.
|
||||
*/
|
||||
fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int)
|
||||
}
|
||||
|
||||
class NotificationsPagingAdapter(
|
||||
diffCallback: DiffUtil.ItemCallback<NotificationViewData>,
|
||||
/** ID of the the account that notifications are being displayed for */
|
||||
private val accountId: String,
|
||||
private val statusActionListener: StatusActionListener,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
var statusDisplayOptions: StatusDisplayOptions
|
||||
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(diffCallback) {
|
||||
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
/** View holders in this adapter must implement this interface */
|
||||
interface ViewHolder {
|
||||
/** Bind the data from the notification and payloads to the view */
|
||||
fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return NotificationViewKind.from(getItem(position)?.type).ordinal
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
|
||||
return when (NotificationViewKind.values()[viewType]) {
|
||||
NotificationViewKind.STATUS -> {
|
||||
StatusViewHolder(
|
||||
ItemStatusBinding.inflate(inflater, parent, false),
|
||||
statusActionListener,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
NotificationViewKind.NOTIFICATION -> {
|
||||
StatusNotificationViewHolder(
|
||||
ItemStatusNotificationBinding.inflate(inflater, parent, false),
|
||||
statusActionListener,
|
||||
notificationActionListener,
|
||||
absoluteTimeFormatter
|
||||
)
|
||||
}
|
||||
NotificationViewKind.FOLLOW -> {
|
||||
FollowViewHolder(
|
||||
ItemFollowBinding.inflate(inflater, parent, false),
|
||||
notificationActionListener,
|
||||
statusActionListener
|
||||
)
|
||||
}
|
||||
NotificationViewKind.FOLLOW_REQUEST -> {
|
||||
FollowRequestViewHolder(
|
||||
ItemFollowRequestBinding.inflate(inflater, parent, false),
|
||||
accountActionListener,
|
||||
statusActionListener,
|
||||
showHeader = true
|
||||
)
|
||||
}
|
||||
NotificationViewKind.REPORT -> {
|
||||
ReportNotificationViewHolder(
|
||||
ItemReportNotificationBinding.inflate(inflater, parent, false),
|
||||
notificationActionListener
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
FallbackNotificationViewHolder(
|
||||
SimpleListItem1Binding.inflate(inflater, parent, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
bindViewHolder(holder, position, null)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
bindViewHolder(holder, position, payloads)
|
||||
}
|
||||
|
||||
private fun bindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<*>?
|
||||
) {
|
||||
getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification view holder to use if no other type is appropriate. Should never normally
|
||||
* be used, but is useful when migrating code.
|
||||
*/
|
||||
private class FallbackNotificationViewHolder(
|
||||
val binding: SimpleListItem1Binding
|
||||
) : ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
binding.text1.text = viewData.statusViewData?.content
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,216 +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.components.notifications
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import okhttp3.Headers
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Models next/prev links from the "Links" header in an API response */
|
||||
data class Links(val next: String?, val prev: String?) {
|
||||
companion object {
|
||||
fun from(linkHeader: String?): Links {
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
return Links(
|
||||
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
|
||||
"max_id"
|
||||
),
|
||||
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
|
||||
"min_id"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
|
||||
class NotificationsPagingSource @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val gson: Gson,
|
||||
private val notificationFilter: Set<Notification.Type>
|
||||
) : PagingSource<String, Notification>() {
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
|
||||
Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}")
|
||||
|
||||
try {
|
||||
val response = when (params) {
|
||||
is LoadParams.Refresh -> {
|
||||
getInitialPage(params)
|
||||
}
|
||||
is LoadParams.Append -> mastodonApi.notifications(
|
||||
maxId = params.key,
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
is LoadParams.Prepend -> mastodonApi.notifications(
|
||||
minId = params.key,
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val code = response.code()
|
||||
|
||||
val msg = response.errorBody()?.string()?.let { errorBody ->
|
||||
if (errorBody.isBlank()) return@let "no reason given"
|
||||
|
||||
val error = try {
|
||||
gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java)
|
||||
} catch (e: Exception) {
|
||||
return@let "$errorBody ($e)"
|
||||
}
|
||||
|
||||
when (val desc = error.error_description) {
|
||||
null -> error.error
|
||||
else -> "${error.error}: $desc"
|
||||
}
|
||||
} ?: "no reason given"
|
||||
return LoadResult.Error(Throwable("HTTP $code: $msg"))
|
||||
}
|
||||
|
||||
val links = Links.from(response.headers()["link"])
|
||||
return LoadResult.Page(
|
||||
data = response.body()!!,
|
||||
nextKey = links.next,
|
||||
prevKey = links.prev
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the initial page of notifications, using params.key as the ID of the initial
|
||||
* notification to fetch.
|
||||
*
|
||||
* - If there is no key, a page of the most recent notifications is returned
|
||||
* - If the notification exists, and is not filtered, a page of notifications is returned
|
||||
* - If the notification does not exist, or is filtered, the page of notifications immediately
|
||||
* before is returned (if non-empty)
|
||||
* - If there is no page of notifications immediately before then the page immediately after
|
||||
* is returned (if non-empty)
|
||||
* - Finally, fall back to the most recent notifications
|
||||
*/
|
||||
private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
|
||||
// If the key is null this is straightforward, just return the most recent notifications.
|
||||
val key = params.key
|
||||
?: return@coroutineScope mastodonApi.notifications(
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
|
||||
// It's important to return *something* from this state. If an empty page is returned
|
||||
// (even with next/prev links) Pager3 assumes there is no more data to load and stops.
|
||||
//
|
||||
// In addition, the Mastodon API does not let you fetch a page that contains a given key.
|
||||
// You can fetch the page immediately before the key, or the page immediately after, but
|
||||
// you can not fetch the page itself.
|
||||
|
||||
// First, try and get the notification itself, and the notifications immediately before
|
||||
// it. This is so that a full page of results can be returned. Returning just the
|
||||
// single notification means the displayed list can jump around a bit as more data is
|
||||
// loaded.
|
||||
//
|
||||
// Make both requests, and wait for the first to complete.
|
||||
val deferredNotification = async { mastodonApi.notification(id = key) }
|
||||
val deferredNotificationPage = async {
|
||||
mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter)
|
||||
}
|
||||
|
||||
val notification = deferredNotification.await()
|
||||
if (notification.isSuccessful) {
|
||||
// If this was successful we must still check that the user is not filtering this type
|
||||
// of notification, as fetching a single notification ignores filters. Returning this
|
||||
// notification if the user is filtering the type is wrong.
|
||||
notification.body()?.let { body ->
|
||||
if (!notificationFilter.contains(body.type)) {
|
||||
// Notification is *not* filtered. We can return this, but need the next page of
|
||||
// notifications as well
|
||||
|
||||
// Collect all notifications in to this list
|
||||
val notifications = mutableListOf(body)
|
||||
val notificationPage = deferredNotificationPage.await()
|
||||
if (notificationPage.isSuccessful) {
|
||||
notificationPage.body()?.let {
|
||||
notifications.addAll(it)
|
||||
}
|
||||
}
|
||||
|
||||
// "notifications" now contains at least one notification we can return, and
|
||||
// hopefully a full page.
|
||||
|
||||
// Build correct max_id and min_id links for the response. The "min_id" to use
|
||||
// when fetching the next page is the same as "key". The "max_id" is the ID of
|
||||
// the oldest notification in the list.
|
||||
val maxId = notifications.last().id
|
||||
val headers = Headers.Builder()
|
||||
.add("link: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; rel=\"prev\"")
|
||||
.build()
|
||||
|
||||
return@coroutineScope Response.success(notifications, headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The user's last read notification was missing or is filtered. Use the page of
|
||||
// notifications chronologically older than their desired notification. This page must
|
||||
// *not* be empty (as noted earlier, if it is, paging stops).
|
||||
deferredNotificationPage.await().let { response ->
|
||||
if (response.isSuccessful) {
|
||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
||||
}
|
||||
}
|
||||
|
||||
// There were no notifications older than the user's desired notification. Return the page
|
||||
// of notifications immediately newer than their desired notification. This page must
|
||||
// *not* be empty (as noted earlier, if it is, paging stops).
|
||||
mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response ->
|
||||
if (response.isSuccessful) {
|
||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
||||
}
|
||||
}
|
||||
|
||||
// Everything failed -- fallback to fetching the most recent notifications
|
||||
return@coroutineScope mastodonApi.notifications(
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
val id = state.closestItemToPosition(anchorPosition)?.id
|
||||
Log.d(TAG, " getRefreshKey returning $id")
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsPagingSource"
|
||||
}
|
||||
}
|
|
@ -1,76 +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.components.notifications
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.InvalidatingPagingSourceFactory
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingSource
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationsRepository @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val gson: Gson
|
||||
) {
|
||||
private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null
|
||||
|
||||
/**
|
||||
* @return flow of Mastodon [Notification], excluding all types in [filter].
|
||||
* Notifications are loaded in [pageSize] increments.
|
||||
*/
|
||||
fun getNotificationsStream(
|
||||
filter: Set<Notification.Type>,
|
||||
pageSize: Int = PAGE_SIZE,
|
||||
initialKey: String? = null
|
||||
): Flow<PagingData<Notification>> {
|
||||
Log.d(TAG, "getNotificationsStream(), filtering: $filter")
|
||||
|
||||
factory = InvalidatingPagingSourceFactory {
|
||||
NotificationsPagingSource(mastodonApi, gson, filter)
|
||||
}
|
||||
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize),
|
||||
initialKey = initialKey,
|
||||
pagingSourceFactory = factory!!
|
||||
).flow
|
||||
}
|
||||
|
||||
/** Invalidate the active paging source, see [PagingSource.invalidate] */
|
||||
fun invalidate() {
|
||||
factory?.invalidate()
|
||||
}
|
||||
|
||||
/** Clear notifications */
|
||||
suspend fun clearNotifications(): Response<ResponseBody> {
|
||||
return mastodonApi.clearNotifications()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsRepository"
|
||||
private const val PAGE_SIZE = 30
|
||||
}
|
||||
}
|
|
@ -1,557 +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.components.notifications
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.deserialize
|
||||
import com.keylesspalace.tusky.util.serialize
|
||||
import com.keylesspalace.tusky.util.throttleFirst
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
data class UiState(
|
||||
/** Filtered notification types */
|
||||
val activeFilter: Set<Notification.Type> = emptySet(),
|
||||
|
||||
/** True if the UI to filter and clear notifications should be shown */
|
||||
val showFilterOptions: Boolean = false,
|
||||
|
||||
/** True if the FAB should be shown while scrolling */
|
||||
val showFabWhileScrolling: Boolean = true
|
||||
)
|
||||
|
||||
/** Preferences the UI reacts to */
|
||||
data class UiPrefs(
|
||||
val showFabWhileScrolling: Boolean,
|
||||
val showFilter: Boolean
|
||||
) {
|
||||
companion object {
|
||||
/** Relevant preference keys. Changes to any of these trigger a display update */
|
||||
val prefKeys = setOf(
|
||||
PrefKeys.FAB_HIDE,
|
||||
PrefKeys.SHOW_NOTIFICATIONS_FILTER
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parent class for all UI actions, fallible or infallible. */
|
||||
sealed class UiAction
|
||||
|
||||
/** Actions the user can trigger from the UI. These actions may fail. */
|
||||
sealed class FallibleUiAction : UiAction() {
|
||||
/** Clear all notifications */
|
||||
object ClearNotifications : FallibleUiAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions the user can trigger from the UI that either cannot fail, or if they do fail,
|
||||
* do not show an error.
|
||||
*/
|
||||
sealed class InfallibleUiAction : UiAction() {
|
||||
/** Apply a new filter to the notification list */
|
||||
// This saves the list to the local database, which triggers a refresh of the data.
|
||||
// Saving the data can't fail, which is why this is infallible. Refreshing the
|
||||
// data may fail, but that's handled by the paging system / adapter refresh logic.
|
||||
data class ApplyFilter(val filter: Set<Notification.Type>) : InfallibleUiAction()
|
||||
|
||||
/**
|
||||
* User is leaving the fragment, save the ID of the visible notification.
|
||||
*
|
||||
* Infallible because if it fails there's nowhere to show the error, and nothing the user
|
||||
* can do.
|
||||
*/
|
||||
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. */
|
||||
sealed class NotificationAction : FallibleUiAction() {
|
||||
data class AcceptFollowRequest(val accountId: String) : NotificationAction()
|
||||
|
||||
data class RejectFollowRequest(val accountId: String) : NotificationAction()
|
||||
}
|
||||
|
||||
sealed class UiSuccess {
|
||||
// These three are from menu items on the status. Currently they don't come to the
|
||||
// viewModel as actions, they're noticed when events are posted. That will change,
|
||||
// but for the moment we can still report them to the UI. Typically, receiving any
|
||||
// of these three should trigger the UI to refresh.
|
||||
|
||||
/** A user was blocked */
|
||||
object Block : UiSuccess()
|
||||
|
||||
/** A user was muted */
|
||||
object Mute : UiSuccess()
|
||||
|
||||
/** A conversation was muted */
|
||||
object MuteConversation : UiSuccess()
|
||||
}
|
||||
|
||||
/** The result of a successful action on a notification */
|
||||
sealed class NotificationActionSuccess(
|
||||
/** String resource with an error message to show the user */
|
||||
@StringRes val msg: Int,
|
||||
|
||||
/**
|
||||
* The original action, in case additional information is required from it to display the
|
||||
* message.
|
||||
*/
|
||||
open val action: NotificationAction
|
||||
) : UiSuccess() {
|
||||
data class AcceptFollowRequest(override val action: NotificationAction) :
|
||||
NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action)
|
||||
data class RejectFollowRequest(override val action: NotificationAction) :
|
||||
NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action)
|
||||
|
||||
companion object {
|
||||
fun from(action: NotificationAction) = when (action) {
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions the user can trigger on an individual status */
|
||||
sealed class StatusAction(
|
||||
open val statusViewData: StatusViewData.Concrete
|
||||
) : FallibleUiAction() {
|
||||
/** Set the bookmark state for a status */
|
||||
data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
|
||||
StatusAction(statusViewData)
|
||||
|
||||
/** Set the favourite state for a status */
|
||||
data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
|
||||
StatusAction(statusViewData)
|
||||
|
||||
/** Set the reblog state for a status */
|
||||
data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) :
|
||||
StatusAction(statusViewData)
|
||||
|
||||
/** Vote in a poll */
|
||||
data class VoteInPoll(
|
||||
val poll: Poll,
|
||||
val choices: List<Int>,
|
||||
override val statusViewData: StatusViewData.Concrete
|
||||
) : StatusAction(statusViewData)
|
||||
}
|
||||
|
||||
/** Changes to a status' visible state after API calls */
|
||||
sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
|
||||
data class Bookmark(override val action: StatusAction.Bookmark) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
data class Favourite(override val action: StatusAction.Favourite) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
data class Reblog(override val action: StatusAction.Reblog) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
data class VoteInPoll(override val action: StatusAction.VoteInPoll) :
|
||||
StatusActionSuccess(action)
|
||||
|
||||
companion object {
|
||||
fun from(action: StatusAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(action)
|
||||
is StatusAction.Favourite -> Favourite(action)
|
||||
is StatusAction.Reblog -> Reblog(action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Errors from fallible view model actions that the UI will need to show */
|
||||
sealed class UiError(
|
||||
/** The exception associated with the error */
|
||||
open val throwable: Throwable,
|
||||
|
||||
/** String resource with an error message to show the user */
|
||||
@StringRes val message: Int,
|
||||
|
||||
/** The action that failed. Can be resent to retry the action */
|
||||
open val action: UiAction? = null
|
||||
) {
|
||||
data class ClearNotifications(override val throwable: Throwable) : UiError(
|
||||
throwable,
|
||||
R.string.ui_error_clear_notifications
|
||||
)
|
||||
|
||||
data class Bookmark(
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Bookmark
|
||||
) : UiError(throwable, R.string.ui_error_bookmark, action)
|
||||
|
||||
data class Favourite(
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Favourite
|
||||
) : UiError(throwable, R.string.ui_error_favourite, action)
|
||||
|
||||
data class Reblog(
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Reblog
|
||||
) : UiError(throwable, R.string.ui_error_reblog, action)
|
||||
|
||||
data class VoteInPoll(
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.VoteInPoll
|
||||
) : UiError(throwable, R.string.ui_error_vote, action)
|
||||
|
||||
data class AcceptFollowRequest(
|
||||
override val throwable: Throwable,
|
||||
override val action: NotificationAction.AcceptFollowRequest
|
||||
) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
|
||||
|
||||
data class RejectFollowRequest(
|
||||
override val throwable: Throwable,
|
||||
override val action: NotificationAction.RejectFollowRequest
|
||||
) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
|
||||
|
||||
companion object {
|
||||
fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(throwable, action)
|
||||
is StatusAction.Favourite -> Favourite(throwable, action)
|
||||
is StatusAction.Reblog -> Reblog(throwable, action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
|
||||
FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
private val repository: NotificationsRepository,
|
||||
private val preferences: SharedPreferences,
|
||||
private val accountManager: AccountManager,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
/** The account to display notifications for */
|
||||
val account = accountManager.activeAccount!!
|
||||
|
||||
val uiState: StateFlow<UiState>
|
||||
|
||||
/** Flow of changes to statusDisplayOptions, for use by the UI */
|
||||
val statusDisplayOptions: StateFlow<StatusDisplayOptions>
|
||||
|
||||
val pagingData: Flow<PagingData<NotificationViewData>>
|
||||
|
||||
/** Flow of user actions received from the UI */
|
||||
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 */
|
||||
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
|
||||
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
|
||||
// (e.g., after a device orientation change) should not re-show the most recent success
|
||||
// message, as it will be confusing to the user.
|
||||
val uiSuccess = MutableSharedFlow<UiSuccess>()
|
||||
|
||||
/** Channel for error results */
|
||||
// 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 */
|
||||
val accept: (UiAction) -> Unit = { action ->
|
||||
viewModelScope.launch { uiAction.emit(action) }
|
||||
}
|
||||
|
||||
init {
|
||||
// Handle changes to notification filters
|
||||
val notificationFilter = uiAction
|
||||
.filterIsInstance<InfallibleUiAction.ApplyFilter>()
|
||||
.distinctUntilChanged()
|
||||
// Save each change back to the active account
|
||||
.onEach { action ->
|
||||
Log.d(TAG, "notificationFilter: $action")
|
||||
account.notificationsFilter = serialize(action.filter)
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
// Load the initial filter from the active account
|
||||
.onStart {
|
||||
emit(
|
||||
InfallibleUiAction.ApplyFilter(
|
||||
filter = deserialize(account.notificationsFilter)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
viewModelScope.launch {
|
||||
uiAction
|
||||
.filterIsInstance<InfallibleUiAction.SaveVisibleId>()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { action ->
|
||||
Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}")
|
||||
account.lastNotificationId = action.visibleId
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial status display options from the user's preferences.
|
||||
//
|
||||
// Then collect future preference changes and emit new values in to
|
||||
// statusDisplayOptions if necessary.
|
||||
statusDisplayOptions = MutableStateFlow(
|
||||
StatusDisplayOptions.from(
|
||||
preferences,
|
||||
account
|
||||
)
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.filterIsInstance<PreferenceChangedEvent>()
|
||||
.filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) }
|
||||
.map {
|
||||
statusDisplayOptions.value.make(
|
||||
preferences,
|
||||
it.preferenceKey,
|
||||
account
|
||||
)
|
||||
}
|
||||
.collect {
|
||||
statusDisplayOptions.emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle UiAction.ClearNotifications
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<FallibleUiAction.ClearNotifications>()
|
||||
.collectLatest {
|
||||
try {
|
||||
repository.clearNotifications().apply {
|
||||
if (this.isSuccessful) {
|
||||
repository.invalidate()
|
||||
} else {
|
||||
_uiErrorChannel.send(UiError.make(HttpException(this), it))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle NotificationAction.*
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<NotificationAction>()
|
||||
.throttleFirst(THROTTLE_TIMEOUT)
|
||||
.collect { action ->
|
||||
try {
|
||||
when (action) {
|
||||
is NotificationAction.AcceptFollowRequest ->
|
||||
timelineCases.acceptFollowRequest(action.accountId).await()
|
||||
is NotificationAction.RejectFollowRequest ->
|
||||
timelineCases.rejectFollowRequest(action.accountId).await()
|
||||
}
|
||||
uiSuccess.emit(NotificationActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle StatusAction.*
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<StatusAction>()
|
||||
.throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps
|
||||
.collect { action ->
|
||||
try {
|
||||
when (action) {
|
||||
is StatusAction.Bookmark ->
|
||||
timelineCases.bookmark(
|
||||
action.statusViewData.actionableId,
|
||||
action.state
|
||||
)
|
||||
is StatusAction.Favourite ->
|
||||
timelineCases.favourite(
|
||||
action.statusViewData.actionableId,
|
||||
action.state
|
||||
)
|
||||
is StatusAction.Reblog ->
|
||||
timelineCases.reblog(
|
||||
action.statusViewData.actionableId,
|
||||
action.state
|
||||
)
|
||||
is StatusAction.VoteInPoll ->
|
||||
timelineCases.voteInPoll(
|
||||
action.statusViewData.actionableId,
|
||||
action.poll.id,
|
||||
action.choices
|
||||
)
|
||||
}.getOrThrow()
|
||||
uiSuccess.emit(StatusActionSuccess.from(action))
|
||||
} catch (t: Throwable) {
|
||||
_uiErrorChannel.send(UiError.make(t, action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle events that should refresh the list
|
||||
viewModelScope.launch {
|
||||
eventHub.events.collectLatest {
|
||||
when (it) {
|
||||
is BlockEvent -> uiSuccess.emit(UiSuccess.Block)
|
||||
is MuteEvent -> uiSuccess.emit(UiSuccess.Mute)
|
||||
is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch notifications if either of `notificationFilter` or `reload` flows have
|
||||
// new items.
|
||||
pagingData = combine(notificationFilter, reload) { action, _ -> action }
|
||||
.flatMapLatest { action ->
|
||||
getNotifications(filters = action.filter, initialKey = getInitialKey())
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
|
||||
UiState(
|
||||
activeFilter = filter.filter,
|
||||
showFilterOptions = prefs.showFilter,
|
||||
showFabWhileScrolling = prefs.showFabWhileScrolling
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
|
||||
initialValue = UiState()
|
||||
)
|
||||
}
|
||||
|
||||
private fun getNotifications(
|
||||
filters: Set<Notification.Type>,
|
||||
initialKey: String? = null
|
||||
): Flow<PagingData<NotificationViewData>> {
|
||||
return repository.getNotificationsStream(filter = filters, initialKey = initialKey)
|
||||
.map { pagingData ->
|
||||
pagingData.map { notification ->
|
||||
notification.toViewData(
|
||||
isShowingContent = statusDisplayOptions.value.showSensitiveMedia ||
|
||||
!(notification.status?.actionableStatus?.sensitive ?: false),
|
||||
isExpanded = statusDisplayOptions.value.openSpoiler,
|
||||
isCollapsed = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The database stores "0" as the last notification ID if notifications have not been
|
||||
// fetched. Convert to null to ensure a full fetch in this case
|
||||
private fun getInitialKey(): String? {
|
||||
val initialKey = when (val id = account.lastNotificationId) {
|
||||
"0" -> null
|
||||
else -> id
|
||||
}
|
||||
Log.d(TAG, "Restoring at $initialKey")
|
||||
return initialKey
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Flow of relevant preferences that change the UI
|
||||
*/
|
||||
// TODO: Preferences should be in a repository
|
||||
private fun getUiPrefs() = eventHub.events
|
||||
.filterIsInstance<PreferenceChangedEvent>()
|
||||
.filter { UiPrefs.prefKeys.contains(it.preferenceKey) }
|
||||
.map { toPrefs() }
|
||||
.onStart { emit(toPrefs()) }
|
||||
|
||||
private fun toPrefs() = UiPrefs(
|
||||
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false),
|
||||
showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsViewModel"
|
||||
private val THROTTLE_TIMEOUT = 500.milliseconds
|
||||
}
|
||||
}
|
|
@ -1,404 +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.components.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.InputFilter
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.text.format.DateUtils
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import net.accelf.yuito.QuoteInlineHelper
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* View holder for a status with an activity to be notified about (posted, boosted,
|
||||
* favourited, or edited, per [NotificationViewKind.from]).
|
||||
*
|
||||
* Shows a line with the activity, and who initiated the activity. Clicking this should
|
||||
* go to the profile page for the initiator.
|
||||
*
|
||||
* Displays the original status below that. Clicking this should go to the original
|
||||
* status in context.
|
||||
*/
|
||||
internal class StatusNotificationViewHolder(
|
||||
private val binding: ItemStatusNotificationBinding,
|
||||
private val statusActionListener: StatusActionListener,
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val absoluteTimeFormatter: AbsoluteTimeFormatter
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_48dp
|
||||
)
|
||||
private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_36dp
|
||||
)
|
||||
private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_24dp
|
||||
)
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
val statusViewData = viewData.statusViewData
|
||||
if (payloads.isNullOrEmpty()) {
|
||||
// Hide null statuses. Shouldn't happen according to the spec, but some servers
|
||||
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
|
||||
if (statusViewData == null) {
|
||||
showNotificationContent(false)
|
||||
} else {
|
||||
showNotificationContent(true)
|
||||
val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
|
||||
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
|
||||
setUsername(account.username)
|
||||
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
|
||||
if (viewData.type == Notification.Type.STATUS ||
|
||||
viewData.type == Notification.Type.UPDATE
|
||||
) {
|
||||
setAvatar(
|
||||
account.avatar,
|
||||
account.bot,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.showBotOverlay
|
||||
)
|
||||
} else {
|
||||
setAvatars(
|
||||
account.avatar,
|
||||
viewData.account.avatar,
|
||||
statusDisplayOptions.animateAvatars
|
||||
)
|
||||
}
|
||||
|
||||
binding.notificationContainer.setOnClickListener {
|
||||
notificationActionListener.onViewThreadForStatus(statusViewData.status)
|
||||
}
|
||||
binding.notificationContent.setOnClickListener {
|
||||
notificationActionListener.onViewThreadForStatus(statusViewData.status)
|
||||
}
|
||||
binding.notificationTopText.setOnClickListener {
|
||||
notificationActionListener.onViewAccount(viewData.account.id)
|
||||
}
|
||||
|
||||
setQuoteContainer(statusViewData.quoteViewData, statusDisplayOptions)
|
||||
}
|
||||
setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
|
||||
} else {
|
||||
for (item in payloads) {
|
||||
if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
|
||||
setCreatedAt(
|
||||
statusViewData.status.actionableStatus.createdAt,
|
||||
statusDisplayOptions.useAbsoluteTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotificationContent(show: Boolean) {
|
||||
binding.statusDisplayName.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.statusUsername.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.statusMetaInfo.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationContentWarningDescription.visibility =
|
||||
if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationContentWarningButton.visibility =
|
||||
if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE
|
||||
binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun setDisplayName(name: String, emojis: List<Emoji>?, animateEmojis: Boolean) {
|
||||
val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
|
||||
binding.statusDisplayName.text = emojifiedName
|
||||
}
|
||||
|
||||
private fun setUsername(name: String) {
|
||||
val context = binding.statusUsername.context
|
||||
val format = context.getString(R.string.post_username_format)
|
||||
val usernameText = String.format(format, name)
|
||||
binding.statusUsername.text = usernameText
|
||||
}
|
||||
|
||||
private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) {
|
||||
if (useAbsoluteTime) {
|
||||
binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
|
||||
} else {
|
||||
// This is the visible timestampInfo.
|
||||
val readout: String
|
||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
val readoutAloud: CharSequence
|
||||
if (createdAt != null) {
|
||||
val then = createdAt.time
|
||||
val now = Date().time
|
||||
readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
|
||||
readoutAloud = DateUtils.getRelativeTimeSpanString(
|
||||
then,
|
||||
now,
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
)
|
||||
} else {
|
||||
// unknown minutes~
|
||||
readout = "?m"
|
||||
readoutAloud = "? minutes"
|
||||
}
|
||||
binding.statusMetaInfo.text = readout
|
||||
binding.statusMetaInfo.contentDescription = readoutAloud
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIconWithColor(
|
||||
context: Context,
|
||||
@DrawableRes drawable: Int,
|
||||
@ColorRes color: Int
|
||||
): Drawable? {
|
||||
val icon = ContextCompat.getDrawable(context, drawable)
|
||||
icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP)
|
||||
return icon
|
||||
}
|
||||
|
||||
private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
|
||||
binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
|
||||
loadAvatar(
|
||||
statusAvatarUrl,
|
||||
binding.notificationStatusAvatar,
|
||||
avatarRadius48dp,
|
||||
animateAvatars
|
||||
)
|
||||
if (showBotOverlay && isBot) {
|
||||
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
||||
Glide.with(binding.notificationNotificationAvatar)
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(binding.notificationNotificationAvatar)
|
||||
} else {
|
||||
binding.notificationNotificationAvatar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
|
||||
val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
|
||||
binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
|
||||
loadAvatar(
|
||||
statusAvatarUrl,
|
||||
binding.notificationStatusAvatar,
|
||||
avatarRadius36dp,
|
||||
animateAvatars
|
||||
)
|
||||
binding.notificationNotificationAvatar.visibility = View.VISIBLE
|
||||
loadAvatar(
|
||||
notificationAvatarUrl,
|
||||
binding.notificationNotificationAvatar,
|
||||
avatarRadius24dp,
|
||||
animateAvatars
|
||||
)
|
||||
}
|
||||
|
||||
fun setMessage(
|
||||
notificationViewData: NotificationViewData,
|
||||
listener: LinkListener,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val statusViewData = notificationViewData.statusViewData
|
||||
val displayName = notificationViewData.account.name.unicodeWrap()
|
||||
val type = notificationViewData.type
|
||||
val context = binding.notificationTopText.context
|
||||
val format: String
|
||||
val icon: Drawable?
|
||||
when (type) {
|
||||
Notification.Type.FAVOURITE -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
||||
format = context.getString(R.string.notification_favourite_format)
|
||||
}
|
||||
Notification.Type.REBLOG -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
|
||||
format = context.getString(R.string.notification_reblog_format)
|
||||
}
|
||||
Notification.Type.STATUS -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
|
||||
format = context.getString(R.string.notification_subscription_format)
|
||||
}
|
||||
Notification.Type.UPDATE -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
|
||||
format = context.getString(R.string.notification_update_format)
|
||||
}
|
||||
else -> {
|
||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
|
||||
format = context.getString(R.string.notification_favourite_format)
|
||||
}
|
||||
}
|
||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(
|
||||
icon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
val wholeMessage = String.format(format, displayName)
|
||||
val str = SpannableStringBuilder(wholeMessage)
|
||||
val displayNameIndex = format.indexOf("%s")
|
||||
str.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
displayNameIndex,
|
||||
displayNameIndex + displayName.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
val emojifiedText = str.emojify(
|
||||
notificationViewData.account.emojis,
|
||||
binding.notificationTopText,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationTopText.text = emojifiedText
|
||||
if (statusViewData != null) {
|
||||
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
|
||||
binding.notificationContentWarningDescription.visibility =
|
||||
if (hasSpoiler) View.VISIBLE else View.GONE
|
||||
binding.notificationContentWarningButton.visibility =
|
||||
if (hasSpoiler) View.VISIBLE else View.GONE
|
||||
if (statusViewData.isExpanded) {
|
||||
binding.notificationContentWarningButton.setText(
|
||||
R.string.post_content_warning_show_less
|
||||
)
|
||||
} else {
|
||||
binding.notificationContentWarningButton.setText(
|
||||
R.string.post_content_warning_show_more
|
||||
)
|
||||
}
|
||||
binding.notificationContentWarningButton.setOnClickListener {
|
||||
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
|
||||
notificationActionListener.onExpandedChange(
|
||||
!statusViewData.isExpanded,
|
||||
bindingAdapterPosition
|
||||
)
|
||||
}
|
||||
binding.notificationContent.visibility =
|
||||
if (statusViewData.isExpanded) View.GONE else View.VISIBLE
|
||||
}
|
||||
setupContentAndSpoiler(listener, statusViewData, animateEmojis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupContentAndSpoiler(
|
||||
listener: LinkListener,
|
||||
statusViewData: StatusViewData.Concrete,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val shouldShowContentIfSpoiler = statusViewData.isExpanded
|
||||
val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
|
||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||
binding.notificationContent.visibility = View.GONE
|
||||
} else {
|
||||
binding.notificationContent.visibility = View.VISIBLE
|
||||
}
|
||||
val content = statusViewData.content
|
||||
val emojis = statusViewData.actionable.emojis
|
||||
if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
|
||||
binding.buttonToggleNotificationContent.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
notificationActionListener.onNotificationContentCollapsedChange(
|
||||
!statusViewData.isCollapsed,
|
||||
position
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.buttonToggleNotificationContent.visibility = View.VISIBLE
|
||||
if (statusViewData.isCollapsed) {
|
||||
binding.buttonToggleNotificationContent.setText(
|
||||
R.string.post_content_warning_show_more
|
||||
)
|
||||
binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
|
||||
} else {
|
||||
binding.buttonToggleNotificationContent.setText(
|
||||
R.string.post_content_warning_show_less
|
||||
)
|
||||
binding.notificationContent.filters = NO_INPUT_FILTER
|
||||
}
|
||||
} else {
|
||||
binding.buttonToggleNotificationContent.visibility = View.GONE
|
||||
binding.notificationContent.filters = NO_INPUT_FILTER
|
||||
}
|
||||
val emojifiedText =
|
||||
content.emojify(
|
||||
emojis,
|
||||
binding.notificationContent,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(
|
||||
binding.notificationContent,
|
||||
emojifiedText,
|
||||
statusViewData.actionable.mentions,
|
||||
statusViewData.actionable.tags,
|
||||
listener
|
||||
)
|
||||
val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify(
|
||||
statusViewData.actionable.emojis,
|
||||
binding.notificationContentWarningDescription,
|
||||
animateEmojis
|
||||
)
|
||||
binding.notificationContentWarningDescription.text = emojifiedContentWarning
|
||||
}
|
||||
|
||||
private fun setQuoteContainer(quote: StatusViewData.Concrete?, statusDisplayOptions: StatusDisplayOptions) {
|
||||
binding.statusQuoteInlineContainer.root.visible(quote != null)
|
||||
if (quote != null) {
|
||||
QuoteInlineHelper(
|
||||
binding.statusQuoteInlineContainer,
|
||||
statusActionListener,
|
||||
avatarRadius24dp,
|
||||
statusDisplayOptions,
|
||||
)
|
||||
.setupQuoteContainer(quote)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
|
||||
private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
|
||||
}
|
||||
}
|
|
@ -1,60 +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.components.notifications
|
||||
|
||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusBinding
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
internal class StatusViewHolder(
|
||||
binding: ItemStatusBinding,
|
||||
private val statusActionListener: StatusActionListener,
|
||||
private val accountId: String
|
||||
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
val statusViewData = viewData.statusViewData
|
||||
if (statusViewData == null) {
|
||||
// Hide null statuses. Shouldn't happen according to the spec, but some servers
|
||||
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
|
||||
showStatusContent(false)
|
||||
} else {
|
||||
if (payloads.isNullOrEmpty()) {
|
||||
showStatusContent(true)
|
||||
}
|
||||
setupWithStatus(
|
||||
statusViewData,
|
||||
statusActionListener,
|
||||
statusDisplayOptions,
|
||||
payloads?.firstOrNull()
|
||||
)
|
||||
}
|
||||
if (viewData.type == Notification.Type.POLL) {
|
||||
setPollInfo(accountId == viewData.account.id)
|
||||
} else {
|
||||
hideStatusInfo()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,9 +30,9 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.TabPreferenceActivity
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity
|
||||
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
|
||||
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
|
@ -156,7 +156,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
setTitle(R.string.title_domain_mutes)
|
||||
setIcon(R.drawable.ic_mute_24dp)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = Intent(context, InstanceListActivity::class.java)
|
||||
val intent = Intent(context, DomainBlocksActivity::class.java)
|
||||
activity?.startActivity(intent)
|
||||
activity?.overridePendingTransition(
|
||||
R.anim.slide_from_right,
|
||||
|
|
|
@ -33,8 +33,9 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
|
||||
import com.keylesspalace.tusky.util.getNonNullString
|
||||
import com.keylesspalace.tusky.util.setAppNightMode
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
|
@ -145,8 +146,8 @@ class PreferencesActivity :
|
|||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
"appTheme" -> {
|
||||
val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT)
|
||||
APP_THEME -> {
|
||||
val theme = sharedPreferences.getNonNullString(APP_THEME, AppTheme.DEFAULT.value)
|
||||
Log.d("activeTheme", theme)
|
||||
setAppNightMode(theme)
|
||||
|
||||
|
@ -157,9 +158,9 @@ class PreferencesActivity :
|
|||
restartActivitiesOnBackPressedCallback.isEnabled = true
|
||||
this.restartCurrentActivity()
|
||||
}
|
||||
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash",
|
||||
"showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites",
|
||||
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE, "viewPagerOffScreenLimit" -> {
|
||||
PrefKeys.STATUS_TEXT_SIZE, PrefKeys.ABSOLUTE_TIME_VIEW, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.ANIMATE_GIF_AVATARS, PrefKeys.USE_BLURHASH,
|
||||
PrefKeys.SHOW_SELF_USERNAME, PrefKeys.SHOW_CARDS_IN_TIMELINES, PrefKeys.CONFIRM_REBLOGS, PrefKeys.CONFIRM_FAVOURITES,
|
||||
PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE, "viewPagerOffScreenLimit" -> {
|
||||
restartActivitiesOnBackPressedCallback.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,7 +81,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
|
||||
preferenceCategory(R.string.pref_title_appearance_settings) {
|
||||
listPreference {
|
||||
setDefaultValue(AppTheme.NIGHT.value)
|
||||
setDefaultValue(AppTheme.DEFAULT.value)
|
||||
setEntries(R.array.app_theme_names)
|
||||
entryValues = AppTheme.stringValues()
|
||||
key = PrefKeys.APP_THEME
|
||||
|
@ -214,13 +214,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
isSingleLineTitle = false
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
setDefaultValue(true)
|
||||
key = PrefKeys.SHOW_NOTIFICATIONS_FILTER
|
||||
setTitle(R.string.pref_title_show_notifications_filter)
|
||||
isSingleLineTitle = false
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
setDefaultValue(true)
|
||||
key = PrefKeys.CONFIRM_REBLOGS
|
||||
|
|
|
@ -19,9 +19,9 @@ import android.os.Bundle
|
|||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.checkBoxPreference
|
||||
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
||||
import com.keylesspalace.tusky.settings.preferenceCategory
|
||||
import com.keylesspalace.tusky.settings.switchPreference
|
||||
|
||||
class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
|
@ -29,19 +29,27 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
|
|||
preferenceCategory(R.string.title_home) { category ->
|
||||
category.isIconSpaceReserved = false
|
||||
|
||||
checkBoxPreference {
|
||||
switchPreference {
|
||||
setTitle(R.string.pref_title_show_boosts)
|
||||
key = PrefKeys.TAB_FILTER_HOME_BOOSTS
|
||||
setDefaultValue(true)
|
||||
isIconSpaceReserved = false
|
||||
}
|
||||
|
||||
checkBoxPreference {
|
||||
switchPreference {
|
||||
setTitle(R.string.pref_title_show_replies)
|
||||
key = PrefKeys.TAB_FILTER_HOME_REPLIES
|
||||
setDefaultValue(true)
|
||||
isIconSpaceReserved = false
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
setTitle(R.string.pref_title_show_self_boosts)
|
||||
setSummary(R.string.pref_title_show_self_boosts_description)
|
||||
key = PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS
|
||||
setDefaultValue(true)
|
||||
isIconSpaceReserved = false
|
||||
}.apply { dependency = PrefKeys.TAB_FILTER_HOME_BOOSTS }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,5 +20,5 @@ enum class Screen {
|
|||
Note,
|
||||
Done,
|
||||
Back,
|
||||
Finish
|
||||
Finish,
|
||||
}
|
||||
|
|
|
@ -103,15 +103,15 @@ class StatusViewHolder(
|
|||
shouldTrimStatus(viewdata.content),
|
||||
viewState.isCollapsed(viewdata.id, true),
|
||||
viewState.isContentShow(viewdata.id, viewdata.status.sensitive),
|
||||
viewdata.spoilerText
|
||||
viewdata.status.spoilerText
|
||||
)
|
||||
|
||||
if (viewdata.spoilerText.isBlank()) {
|
||||
if (viewdata.status.spoilerText.isBlank()) {
|
||||
setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
|
||||
binding.statusContentWarningButton.hide()
|
||||
binding.statusContentWarningDescription.hide()
|
||||
} else {
|
||||
val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
|
||||
val emojiSpoiler = viewdata.status.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
|
||||
binding.statusContentWarningDescription.text = emojiSpoiler
|
||||
binding.statusContentWarningDescription.show()
|
||||
binding.statusContentWarningButton.show()
|
||||
|
|
|
@ -150,12 +150,12 @@ class ReportStatusesFragment :
|
|||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = false,
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||
showBotOverlay = false,
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||
cardViewMode = CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
||||
|
|
|
@ -127,7 +127,7 @@ class ScheduledStatusActivity :
|
|||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.activity_announcements, menu)
|
||||
menuInflater.inflate(R.menu.activity_scheduled_status, menu)
|
||||
menu.findItem(R.id.action_search)?.apply {
|
||||
icon = IconicsDrawable(this@ScheduledStatusActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||
sizeDp = 20
|
||||
|
@ -163,8 +163,8 @@ class ScheduledStatusActivity :
|
|||
visibility = item.params.visibility,
|
||||
scheduledAt = item.scheduledAt,
|
||||
sensitive = item.params.sensitive,
|
||||
kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED
|
||||
)
|
||||
kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED,
|
||||
),
|
||||
)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
|
|
@ -107,7 +107,7 @@ abstract class SearchFragment<T : Any> :
|
|||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.fragment_timeline, menu)
|
||||
menuInflater.inflate(R.menu.fragment_search, menu)
|
||||
menu.findItem(R.id.action_refresh)?.apply {
|
||||
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
|
||||
sizeDp = 20
|
||||
|
|
|
@ -78,14 +78,14 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
override fun createAdapter(): PagingDataAdapter<StatusViewData.Concrete, *> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
mediaPreviewEnabled = viewModel.mediaPreviewEnabled,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||
cardViewMode = CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
||||
|
|
|
@ -46,7 +46,6 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.QuickReplyEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusEditedEvent
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
|
||||
|
@ -293,7 +292,7 @@ class TimelineFragment :
|
|||
|
||||
if (actionButtonPresent()) {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
hideFab = preferences.getBoolean("fabHide", false)
|
||||
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
|
@ -322,9 +321,6 @@ class TimelineFragment :
|
|||
val status = event.status
|
||||
handleStatusComposeEvent(status)
|
||||
}
|
||||
is StatusEditedEvent -> {
|
||||
handleStatusComposeEvent(event.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -567,7 +563,8 @@ class TimelineFragment :
|
|||
when (kind) {
|
||||
TimelineViewModel.Kind.HOME,
|
||||
TimelineViewModel.Kind.PUBLIC_FEDERATED,
|
||||
TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh()
|
||||
TimelineViewModel.Kind.PUBLIC_LOCAL,
|
||||
TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh()
|
||||
TimelineViewModel.Kind.USER,
|
||||
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
|
||||
adapter.refresh()
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
package com.keylesspalace.tusky.components.timeline.util
|
||||
|
||||
import com.google.gson.JsonParseException
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
fun Throwable.isExpected() = this is IOException || this is HttpException
|
||||
fun Throwable.isExpected() = this is IOException || this is HttpException || this is JsonParseException
|
||||
|
||||
inline fun <T> ifExpected(
|
||||
t: Throwable,
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.timeline.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
|
@ -117,6 +118,7 @@ class CachedTimelineRemoteMediator(
|
|||
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
|
||||
} catch (e: Exception) {
|
||||
return ifExpected(e) {
|
||||
Log.w(TAG, "Failed to load timeline", e)
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
@ -175,4 +177,8 @@ class CachedTimelineRemoteMediator(
|
|||
}
|
||||
return overlappedStatuses
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CachedTimelineRM"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,11 +27,7 @@ import androidx.paging.filter
|
|||
import androidx.paging.map
|
||||
import androidx.room.withTransaction
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
||||
import com.keylesspalace.tusky.appstore.PinEvent
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST
|
||||
import com.keylesspalace.tusky.components.timeline.Placeholder
|
||||
|
@ -254,19 +250,7 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
}
|
||||
|
||||
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun handleFavEvent(favEvent: FavoriteEvent) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun handlePinEvent(pinEvent: PinEvent) {
|
||||
override fun handleStatusChangedEvent(status: Status) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue