v23.0
-----BEGIN PGP SIGNATURE----- Version: BCPG v1.72 iQJDBAABCAAtBQJkrEbnFiEEj23e8Mh/GILvvT0B+VJoFZwuyJcPHG5pa0BuZ28u b3JnLnVrAAoJEPlSaBWcLsiX5oEQANeQ26a9Hman++Ox+gXMP9+l4f1YzRq7FTmJ YhX5SJOfz1KN1Fbmv5ZgyI4nFUCJUASqBYube2LVV6m0mG1B4FeDAXyidUtQjH80 Fv2H7E3BlJ1Y/Zo660/eBoTAIYothu2ukWBl8MmDeb/LpUhZ7NPPd7r8C0wD+HIJ 1cnwDE9e7GunqsIvlg3hSzaqgSL3+EwyR2/iWMgI1X/qSDszIbk6QKq6nGP7+oLP +It3bQTGljgIJD+U0WpgqV+rKxV5/47RO0K+CPz7I2KpXK9GKSQ123hKxKuyndCY LajC9qel05aL7ufvzL8+BO2ucQJESuM1LEWB1YBgD6kTCuTrDlQHTsDCc3uOLjkO smlXc/tPsTAl0w/NXTrD/G6poW9yoirk4rpAAG3r8uFxzFNce5qNRGuD3TEbsBVQ QVmFnbxfrRZzGqcPDfH3yBn1VI7PEquM7NUp2d1PHCv/VKB4st7b7Z2oZRtr4Tv4 vAIwsBqoGu71wGtdsx+8swPNxP/m8F7ROzvcf58M38tiYSzx+5fhJ6ffgpQ5leSu R7JiLws5q3FLS74mO3gs9R9xitEjTCG2+BDMLtyc30hEveELG6rorUAGtg1RAhge kuaLE3pz8Sxw4UbRIEnFEjLlt4EDlL4Ttwgnaxy/VTUvLJ6o7h9yawZXi3yunwgZ SB6u20dK =RrjC -----END PGP SIGNATURE----- Merge Tusky 23, prep for graphics refresh
|
Before Width: | Height: | Size: 4.6 KiB |
BIN
app/src/green/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
BIN
app/src/green/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
BIN
app/src/green/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
BIN
app/src/green/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 14 KiB |
BIN
app/src/green/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -122,7 +122,7 @@
|
|||
<activity android:name=".EditProfileActivity" />
|
||||
<activity android:name=".components.preference.PreferencesActivity" />
|
||||
<activity android:name=".StatusListActivity" />
|
||||
<activity android:name=".AccountListActivity" />
|
||||
<activity android:name=".components.accountlist.AccountListActivity" />
|
||||
<activity android:name=".AboutActivity" />
|
||||
<activity android:name=".TabPreferenceActivity" />
|
||||
<activity
|
||||
|
|
@ -142,7 +142,8 @@
|
|||
</activity>
|
||||
<activity android:name=".ListsActivity" />
|
||||
<activity android:name=".LicenseActivity" />
|
||||
<activity android:name=".FiltersActivity" />
|
||||
<activity android:name=".components.filters.FiltersActivity" />
|
||||
<activity android:name=".components.trending.TrendingActivity" />
|
||||
<activity android:name=".components.followedtags.FollowedTagsActivity" />
|
||||
<activity
|
||||
android:name=".components.report.ReportActivity"
|
||||
|
|
@ -151,9 +152,9 @@
|
|||
<activity android:name=".components.scheduled.ScheduledStatusActivity" />
|
||||
<activity android:name=".components.announcements.AnnouncementsActivity" />
|
||||
<activity android:name=".components.drafts.DraftsActivity" />
|
||||
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<receiver android:name=".receiver.NotificationClearBroadcastReceiver"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
}
|
||||
|
||||
private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) {
|
||||
|
||||
val text = SpannableString(context.getText(textId))
|
||||
|
||||
Linkify.addLinks(text, Linkify.WEB_URLS)
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ import com.keylesspalace.tusky.util.emojify
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.State
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
|
||||
|
|
@ -63,10 +63,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
private val adapter = Adapter()
|
||||
private val searchAdapter = SearchAdapter()
|
||||
|
||||
private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
|
||||
private val pm by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
|
||||
private val animateAvatar by lazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
|
||||
private val animateEmojis by lazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
|
||||
private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
|
||||
private val pm by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
|
||||
private val animateAvatar by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
|
||||
private val animateEmojis by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -113,7 +113,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
binding.searchView.isSubmitButtonEnabled = true
|
||||
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
viewModel.search(query ?: "")
|
||||
viewModel.search(query.orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -145,21 +145,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
|
||||
private fun handleError(error: Throwable) {
|
||||
binding.messageView.show()
|
||||
val retryAction = { _: View ->
|
||||
binding.messageView.setup(error) { _: View ->
|
||||
binding.messageView.hide()
|
||||
viewModel.load(listId)
|
||||
}
|
||||
if (error is IOException) {
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network, retryAction
|
||||
)
|
||||
} else {
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic, retryAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRemoveFromList(accountId: String) {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Color;
|
||||
|
|
@ -45,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.PrefKeys;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -54,6 +57,7 @@ import java.util.List;
|
|||
import javax.inject.Inject;
|
||||
|
||||
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
|
||||
private static final String TAG = "BaseActivity";
|
||||
|
||||
@Inject
|
||||
public AccountManager accountManager;
|
||||
|
|
@ -79,7 +83,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
/* set the taskdescription programmatically, the theme would turn it blue */
|
||||
String appName = getString(R.string.app_name);
|
||||
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
|
||||
int recentsBackgroundColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK);
|
||||
int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK);
|
||||
|
||||
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
|
||||
|
||||
|
|
@ -93,6 +97,44 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
requesters = new HashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context newBase) {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
|
||||
|
||||
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
|
||||
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
|
||||
|
||||
Configuration configuration = newBase.getResources().getConfiguration();
|
||||
|
||||
// Adjust `fontScale` in the configuration.
|
||||
//
|
||||
// You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the
|
||||
// result of previous adjustments. E.g., going from 100% to 80% to 100% does not return
|
||||
// you to the original 100%, it leaves it at 80%.
|
||||
//
|
||||
// Instead, calculate the new scale from the application context. This is unaffected by
|
||||
// changes to the base context. It does contain contain any changes to the font scale from
|
||||
// "Settings > Display > Font size" in the device settings, so scaling performed here
|
||||
// is in addition to any scaling in the device settings.
|
||||
Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration();
|
||||
|
||||
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
|
||||
// You can try to adjust `densityDpi` as shown in the commented out code below. This
|
||||
// works, to a point. However, dialogs do not react well to this. Beyond a certain
|
||||
// scale (~ 120%) the right hand edge of the dialog will clip off the right of the
|
||||
// screen.
|
||||
//
|
||||
// So for now, just adjust the font scale
|
||||
//
|
||||
// val displayMetrics = appContext.resources.displayMetrics
|
||||
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
|
||||
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
|
||||
|
||||
Context fontScaleContext = newBase.createConfigurationContext(configuration);
|
||||
|
||||
super.attachBaseContext(fontScaleContext);
|
||||
}
|
||||
|
||||
protected boolean requiresLogin() {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -213,7 +255,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
|
||||
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
|
||||
accountManager.setActiveAccount(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);
|
||||
|
|
@ -239,8 +281,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
if (permissionsToRequest.isEmpty()) {
|
||||
int[] permissionsAlreadyGranted = new int[permissions.length];
|
||||
for (int i = 0; i < permissionsAlreadyGranted.length; ++i)
|
||||
permissionsAlreadyGranted[i] = PackageManager.PERMISSION_GRANTED;
|
||||
requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,8 +86,11 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
if (statuses.isNotEmpty()) {
|
||||
viewThread(statuses[0].id, statuses[0].url)
|
||||
return@subscribe
|
||||
} else if (accounts.isNotEmpty()) {
|
||||
viewAccount(accounts[0].id)
|
||||
}
|
||||
accounts.firstOrNull { it.url == url }?.let { account ->
|
||||
// Some servers return (unrelated) accounts for url searches (#2804)
|
||||
// Verify that the account's url matches the query
|
||||
viewAccount(account.id)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
|
|
@ -174,5 +177,5 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
|
||||
enum class PostLookupFallbackBehavior {
|
||||
OPEN_IN_BROWSER,
|
||||
DISPLAY_ERROR,
|
||||
DISPLAY_ERROR
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import com.bumptech.glide.Glide
|
|||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.FitCenter
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||
import com.canhub.cropper.CropImage
|
||||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
|
@ -80,14 +81,18 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
|
||||
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
|
||||
if (result.isSuccessful) {
|
||||
if (result.uriContent == viewModel.getAvatarUri()) {
|
||||
viewModel.newAvatarPicked()
|
||||
} else {
|
||||
viewModel.newHeaderPicked()
|
||||
}
|
||||
if (result is CropImage.CancelledResult) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
if (!result.isSuccessful) {
|
||||
return@registerForActivityResult onPickFailure(result.error)
|
||||
}
|
||||
|
||||
if (result.uriContent == viewModel.getAvatarUri()) {
|
||||
viewModel.newAvatarPicked()
|
||||
} else {
|
||||
onPickFailure(result.error)
|
||||
viewModel.newHeaderPicked()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,12 +136,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
is Success -> {
|
||||
val me = profileRes.data
|
||||
if (me != null) {
|
||||
|
||||
binding.displayNameEditText.setText(me.displayName)
|
||||
binding.noteEditText.setText(me.source?.note)
|
||||
binding.lockedCheckBox.isChecked = me.locked
|
||||
|
||||
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
|
||||
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
|
||||
binding.addFieldButton.isVisible =
|
||||
(me.source?.fields?.size ?: 0) < maxAccountFields
|
||||
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.view.getSecondsForDurationIndex
|
||||
import com.keylesspalace.tusky.view.setupEditDialogForFilter
|
||||
import com.keylesspalace.tusky.view.showAddFilterDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class FiltersActivity : BaseActivity() {
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val binding by viewBinding(ActivityFiltersBinding::inflate)
|
||||
|
||||
private lateinit var context: String
|
||||
private lateinit var filters: MutableList<Filter>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
supportActionBar?.run {
|
||||
// Back button
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
binding.addFilterButton.setOnClickListener {
|
||||
showAddFilterDialog(this)
|
||||
}
|
||||
|
||||
title = intent?.getStringExtra(FILTERS_TITLE)
|
||||
context = intent?.getStringExtra(FILTERS_CONTEXT)!!
|
||||
loadFilters()
|
||||
}
|
||||
|
||||
fun updateFilter(id: String, phrase: String, filterContext: List<String>, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) {
|
||||
lifecycleScope.launch {
|
||||
api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold(
|
||||
{ updatedFilter ->
|
||||
if (updatedFilter.context.contains(context)) {
|
||||
filters[itemIndex] = updatedFilter
|
||||
} else {
|
||||
filters.removeAt(itemIndex)
|
||||
}
|
||||
refreshFilterDisplay()
|
||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
||||
},
|
||||
{
|
||||
Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFilter(itemIndex: Int) {
|
||||
val filter = filters[itemIndex]
|
||||
if (filter.context.size == 1) {
|
||||
lifecycleScope.launch {
|
||||
// This is the only context for this filter; delete it
|
||||
api.deleteFilter(filters[itemIndex].id).fold(
|
||||
{
|
||||
filters.removeAt(itemIndex)
|
||||
refreshFilterDisplay()
|
||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
||||
},
|
||||
{
|
||||
Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Keep the filter, but remove it from this context
|
||||
val oldFilter = filters[itemIndex]
|
||||
val newFilter = Filter(
|
||||
oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
|
||||
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
|
||||
)
|
||||
updateFilter(
|
||||
newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord,
|
||||
getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) {
|
||||
lifecycleScope.launch {
|
||||
api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold(
|
||||
{ filter ->
|
||||
filters.add(filter)
|
||||
refreshFilterDisplay()
|
||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
||||
},
|
||||
{
|
||||
Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshFilterDisplay() {
|
||||
binding.filtersView.adapter = ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_list_item_1,
|
||||
filters.map { filter ->
|
||||
if (filter.expiresAt == null) {
|
||||
filter.phrase
|
||||
} else {
|
||||
getString(
|
||||
R.string.filter_expiration_format,
|
||||
filter.phrase,
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
filter.expiresAt.time,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) }
|
||||
}
|
||||
|
||||
private fun loadFilters() {
|
||||
|
||||
binding.filterMessageView.hide()
|
||||
binding.filtersView.hide()
|
||||
binding.addFilterButton.hide()
|
||||
binding.filterProgressBar.show()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val newFilters = api.getFilters().getOrElse {
|
||||
binding.filterProgressBar.hide()
|
||||
binding.filterMessageView.show()
|
||||
if (it is IOException) {
|
||||
binding.filterMessageView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network
|
||||
) { loadFilters() }
|
||||
} else {
|
||||
binding.filterMessageView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic
|
||||
) { loadFilters() }
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
filters = newFilters.filter { it.context.contains(context) }.toMutableList()
|
||||
refreshFilterDisplay()
|
||||
|
||||
binding.filtersView.show()
|
||||
binding.addFilterButton.show()
|
||||
binding.filterProgressBar.hide()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILTERS_CONTEXT = "filters_context"
|
||||
const val FILTERS_TITLE = "filters_title"
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,6 @@ class LicenseActivity : BaseActivity() {
|
|||
}
|
||||
|
||||
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
|
||||
|
||||
val sb = StringBuilder()
|
||||
|
||||
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import android.widget.TextView
|
|||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
|
|
@ -45,7 +46,6 @@ import com.keylesspalace.tusky.di.Injectable
|
|||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
|
@ -101,6 +101,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() }
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.state.collect(this@ListsActivity::update)
|
||||
}
|
||||
|
|
@ -113,7 +116,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
|
||||
lifecycleScope.launch {
|
||||
viewModel.events.collect { event ->
|
||||
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
|
||||
when (event) {
|
||||
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
|
||||
|
|
@ -135,8 +137,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(layout)
|
||||
.setPositiveButton(
|
||||
if (list == null) R.string.action_create_list
|
||||
else R.string.action_rename_list
|
||||
if (list == null) {
|
||||
R.string.action_create_list
|
||||
} else {
|
||||
R.string.action_rename_list
|
||||
}
|
||||
) { _, _ ->
|
||||
onPickedDialogName(editText.text, list?.id)
|
||||
}
|
||||
|
|
@ -144,8 +149,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
.show()
|
||||
|
||||
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
|
||||
editText.onTextChanged { s, _, _, _ ->
|
||||
positiveButton.isEnabled = s.isNotBlank()
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
positiveButton.isEnabled = s?.isNotBlank() == true
|
||||
}
|
||||
editText.setText(list?.title)
|
||||
editText.text?.let { editText.setSelection(it.length) }
|
||||
|
|
@ -164,6 +169,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
private fun update(state: ListsViewModel.State) {
|
||||
adapter.submitList(state.lists)
|
||||
binding.progressBar.visible(state.loadingState == LOADING)
|
||||
binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING
|
||||
when (state.loadingState) {
|
||||
INITIAL, LOADING -> binding.messageView.hide()
|
||||
ERROR_NETWORK -> {
|
||||
|
|
@ -182,7 +188,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
if (state.lists.isEmpty()) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_friend_empty, R.string.message_empty,
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
|
|
@ -193,7 +200,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
|
||||
private fun showMessage(@StringRes messageId: Int) {
|
||||
Snackbar.make(
|
||||
binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT
|
||||
binding.listsRecycler,
|
||||
messageId,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,24 +27,27 @@ import android.graphics.drawable.BitmapDrawable
|
|||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import autodispose2.androidx.lifecycle.autoDispose
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||
|
|
@ -57,11 +60,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.Event
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
|
||||
|
|
@ -74,6 +77,7 @@ import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNec
|
|||
import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
import com.keylesspalace.tusky.components.trending.TrendingActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
|
|
@ -81,6 +85,7 @@ import com.keylesspalace.tusky.entity.Account
|
|||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.FabFragment
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.pager.MainPagerAdapter
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
|
@ -92,6 +97,7 @@ import com.keylesspalace.tusky.util.getDimension
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
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
|
||||
|
|
@ -125,12 +131,11 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
|
|||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
|
||||
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
|
|
@ -158,7 +163,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
private var unreadAnnouncementsCount = 0
|
||||
|
||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
private lateinit var glide: RequestManager
|
||||
|
||||
|
|
@ -167,6 +172,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
// We need to know if the emoji pack has been changed
|
||||
private var selectedEmojiPack: String? = null
|
||||
|
||||
/** Mediate between binding.viewPager and the chosen tab layout */
|
||||
private var tabLayoutMediator: TabLayoutMediator? = null
|
||||
|
||||
/** Adapter for the different timeline tabs */
|
||||
private lateinit var tabAdapter: MainPagerAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
|
@ -201,7 +212,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
} else {
|
||||
// No account was provided, show the chooser
|
||||
showAccountChooserDialog(
|
||||
getString(R.string.action_share_as), true,
|
||||
getString(R.string.action_share_as),
|
||||
true,
|
||||
object : AccountSelectionListener {
|
||||
override fun onAccountSelected(account: AccountEntity) {
|
||||
val requestedId = account.id
|
||||
|
|
@ -233,6 +245,7 @@ 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)
|
||||
|
||||
|
|
@ -246,21 +259,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
|
||||
|
||||
binding.mainToolbar.menu.add(R.string.action_search).apply {
|
||||
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
|
||||
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||
sizeDp = 20
|
||||
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
|
||||
}
|
||||
setOnMenuItemClickListener {
|
||||
startActivity(SearchActivity.getIntent(this@MainActivity))
|
||||
true
|
||||
}
|
||||
}
|
||||
addMenuProvider(this)
|
||||
|
||||
binding.viewPager.reduceSwipeSensitivity()
|
||||
|
||||
setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar)
|
||||
setupDrawer(
|
||||
savedInstanceState,
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
|
||||
)
|
||||
|
||||
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
||||
* drawer, though, because its callback touches the header in the drawer. */
|
||||
|
|
@ -268,21 +275,35 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
fetchAnnouncements()
|
||||
|
||||
// Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the
|
||||
// adapter changes over the life of the viewPager (the adapter, not its contents), so set
|
||||
// the initial list of tabs to empty, and set the full list later in setupTabs(). See
|
||||
// https://github.com/tuskyapp/Tusky/issues/3251 for details.
|
||||
tabAdapter = MainPagerAdapter(emptyList(), this)
|
||||
binding.viewPager.adapter = tabAdapter
|
||||
|
||||
setupTabs(showNotificationTab)
|
||||
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe { event: Event? ->
|
||||
lifecycleScope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
when (event) {
|
||||
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
|
||||
is MainTabsChangedEvent -> setupTabs(false)
|
||||
is MainTabsChangedEvent -> {
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !event.newTabs.hasTab(TRENDING)
|
||||
)
|
||||
|
||||
setupTabs(false)
|
||||
}
|
||||
|
||||
is AnnouncementReadEvent -> {
|
||||
unreadAnnouncementsCount--
|
||||
updateAnnouncementsBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Schedulers.io().scheduleDirect {
|
||||
// Flush old media that was cached for sharing
|
||||
|
|
@ -322,9 +343,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
draftsAlert.observeInContext(this, true)
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.activity_main, menu)
|
||||
menu.findItem(R.id.action_search)?.apply {
|
||||
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||
sizeDp = 20
|
||||
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_search -> {
|
||||
startActivity(SearchActivity.getIntent(this@MainActivity))
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
NotificationHelper.clearNotificationsForActiveAccount(this, accountManager)
|
||||
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
|
||||
if (currentEmojiPack != selectedEmojiPack) {
|
||||
Log.d(
|
||||
|
|
@ -364,7 +404,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
// FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_N -> {
|
||||
|
||||
// open compose activity by pressing SHIFT + N (or CTRL + N)
|
||||
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
|
||||
startActivity(composeIntent)
|
||||
|
|
@ -396,8 +435,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
finish()
|
||||
}
|
||||
|
||||
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
|
||||
|
||||
private fun setupDrawer(
|
||||
savedInstanceState: Bundle?,
|
||||
addSearchButton: Boolean,
|
||||
addTrendingButton: Boolean
|
||||
) {
|
||||
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
||||
|
||||
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
|
|
@ -422,6 +464,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
closeDrawerOnProfileListClick = true
|
||||
}
|
||||
|
||||
header.currentProfileName.maxLines = 1
|
||||
header.currentProfileName.ellipsize = TextUtils.TruncateAt.END
|
||||
|
||||
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
|
||||
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
|
|
@ -454,6 +499,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
})
|
||||
|
||||
binding.mainDrawer.apply {
|
||||
refreshMainDrawerItems(addSearchButton, addTrendingButton)
|
||||
setSavedInstance(savedInstanceState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
|
||||
binding.mainDrawer.apply {
|
||||
itemAdapter.clear()
|
||||
tintStatusBar = true
|
||||
addItems(
|
||||
primaryDrawerItem {
|
||||
|
|
@ -519,8 +572,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
|
||||
}
|
||||
badgeStyle = BadgeStyle().apply {
|
||||
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary))
|
||||
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary))
|
||||
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary))
|
||||
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary))
|
||||
}
|
||||
},
|
||||
DividerDrawerItem(),
|
||||
|
|
@ -568,7 +621,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
)
|
||||
}
|
||||
|
||||
setSavedInstance(savedInstanceState)
|
||||
if (addTrendingButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
5,
|
||||
primaryDrawerItem {
|
||||
nameRes = R.string.title_public_trending_hashtags
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
|
||||
onClick = {
|
||||
startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
|
@ -617,8 +681,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun setupTabs(selectNotificationTab: Boolean) {
|
||||
val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") {
|
||||
val actionBarSize = getDimension(this, R.attr.actionBarSize)
|
||||
val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") {
|
||||
val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize)
|
||||
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
|
||||
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
|
||||
binding.topNav.hide()
|
||||
|
|
@ -630,29 +694,36 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
binding.tabLayout
|
||||
}
|
||||
|
||||
// Save the previous tab so it can be restored later
|
||||
val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem)
|
||||
|
||||
val tabs = accountManager.activeAccount!!.tabPreferences
|
||||
|
||||
val adapter = MainPagerAdapter(tabs, this)
|
||||
binding.viewPager.adapter = adapter
|
||||
TabLayoutMediator(activeTabLayout, binding.viewPager) { _: TabLayout.Tab?, _: Int -> }.attach()
|
||||
activeTabLayout.removeAllTabs()
|
||||
for (i in tabs.indices) {
|
||||
val tab = activeTabLayout.newTab()
|
||||
.setIcon(tabs[i].icon)
|
||||
if (tabs[i].id == LIST) {
|
||||
tab.contentDescription = tabs[i].arguments[1]
|
||||
} else {
|
||||
tab.setContentDescription(tabs[i].text)
|
||||
}
|
||||
activeTabLayout.addTab(tab)
|
||||
// Detach any existing mediator before changing tab contents and attaching a new mediator
|
||||
tabLayoutMediator?.detach()
|
||||
|
||||
if (tabs[i].id == NOTIFICATIONS) {
|
||||
notificationTabPosition = i
|
||||
if (selectNotificationTab) {
|
||||
tab.select()
|
||||
}
|
||||
tabAdapter.tabs = tabs
|
||||
tabAdapter.notifyItemRangeChanged(0, tabs.size)
|
||||
|
||||
tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) {
|
||||
tab: TabLayout.Tab, position: Int ->
|
||||
tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon)
|
||||
tab.contentDescription = when (tabs[position].id) {
|
||||
LIST -> tabs[position].arguments[1]
|
||||
else -> getString(tabs[position].text)
|
||||
}
|
||||
}
|
||||
}.also { it.attach() }
|
||||
|
||||
// Selected tab is either
|
||||
// - Notification tab (if appropriate)
|
||||
// - The previously selected tab (if it hasn't been removed)
|
||||
// - Left-most tab
|
||||
val position = if (selectNotificationTab) {
|
||||
tabs.indexOfFirst { it.id == NOTIFICATIONS }
|
||||
} else {
|
||||
previousTab?.let { tabs.indexOfFirst { it == previousTab } }
|
||||
}.takeIf { it != -1 } ?: 0
|
||||
binding.viewPager.setCurrentItem(position, false)
|
||||
|
||||
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
|
||||
binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin))
|
||||
|
|
@ -666,34 +737,48 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
onTabSelectedListener = object : OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
if (tab.position == notificationTabPosition) {
|
||||
NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager)
|
||||
}
|
||||
binding.mainToolbar.title = tab.contentDescription
|
||||
|
||||
binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity)
|
||||
refreshComposeButtonState(tabAdapter, tab.position)
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||
|
||||
override fun onTabReselected(tab: TabLayout.Tab) {
|
||||
val fragment = adapter.getFragment(tab.position)
|
||||
val fragment = tabAdapter.getFragment(tab.position)
|
||||
if (fragment is ReselectableFragment) {
|
||||
(fragment as ReselectableFragment).onReselect()
|
||||
}
|
||||
|
||||
refreshComposeButtonState(tabAdapter, tab.position)
|
||||
}
|
||||
}.also {
|
||||
activeTabLayout.addOnTabSelectedListener(it)
|
||||
}
|
||||
|
||||
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
|
||||
binding.mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity)
|
||||
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
|
||||
binding.mainToolbar.setOnClickListener {
|
||||
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
|
||||
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
|
||||
}
|
||||
|
||||
updateProfiles()
|
||||
}
|
||||
|
||||
private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {
|
||||
adapter.getFragment(tabPosition)?.also { fragment ->
|
||||
if (fragment is FabFragment) {
|
||||
if (fragment.isFabVisible()) {
|
||||
binding.composeButton.show()
|
||||
} else {
|
||||
binding.composeButton.hide()
|
||||
}
|
||||
} else {
|
||||
binding.composeButton.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
|
||||
val activeAccount = accountManager.activeAccount
|
||||
|
||||
|
|
@ -794,7 +879,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
|
||||
|
||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
|
||||
|
|
@ -822,7 +906,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
.into(avatarView)
|
||||
}
|
||||
} else {
|
||||
|
||||
binding.bottomNavAvatar.hide()
|
||||
binding.topNavAvatar.hide()
|
||||
|
||||
|
|
@ -929,16 +1012,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
private fun updateProfiles() {
|
||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
|
||||
ProfileDrawerItem().apply {
|
||||
isSelected = acc.isActive
|
||||
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
|
||||
iconUrl = acc.profilePictureUrl
|
||||
isNameShown = true
|
||||
identifier = acc.id
|
||||
descriptionText = acc.fullName
|
||||
}
|
||||
}.toMutableList()
|
||||
val profiles: MutableList<IProfile> =
|
||||
accountManager.getAllAccountsOrderedByActive().map { acc ->
|
||||
ProfileDrawerItem().apply {
|
||||
isSelected = acc.isActive
|
||||
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
|
||||
iconUrl = acc.profilePictureUrl
|
||||
isNameShown = true
|
||||
identifier = acc.id
|
||||
descriptionText = acc.fullName
|
||||
}
|
||||
}.toMutableList()
|
||||
|
||||
// reuse the already existing "add account" item
|
||||
for (profile in header.profiles.orEmpty()) {
|
||||
|
|
@ -952,7 +1036,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
header.setActiveProfile(accountManager.activeAccount!!.id)
|
||||
binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) {
|
||||
accountManager.activeAccount!!.fullName
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getActionButton() = binding.composeButton
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ 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.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterV1
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
|
@ -54,6 +56,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
private var unmuteTagItem: MenuItem? = null
|
||||
|
||||
/** The filter muting hashtag, null if unknown or hashtag is not filtered */
|
||||
private var mutedFilterV1: FilterV1? = null
|
||||
private var mutedFilter: Filter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -174,49 +177,89 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
lifecycleScope.launch {
|
||||
mastodonApi.getFilters().fold(
|
||||
{ filters ->
|
||||
for (filter in filters) {
|
||||
if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) {
|
||||
Log.d(TAG, "Tag $hashtag is filtered")
|
||||
muteTagItem?.isVisible = false
|
||||
unmuteTagItem?.isVisible = true
|
||||
mutedFilter = filter
|
||||
return@fold
|
||||
mutedFilter = filters.firstOrNull { filter ->
|
||||
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
|
||||
it.keyword == tag
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Tag $hashtag is not filtered")
|
||||
mutedFilter = null
|
||||
muteTagItem?.isEnabled = true
|
||||
muteTagItem?.isVisible = true
|
||||
muteTagItem?.isVisible = true
|
||||
updateTagMuteState(mutedFilter != null)
|
||||
},
|
||||
{ throwable ->
|
||||
Log.e(TAG, "Error getting filters: $throwable")
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
mastodonApi.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
mutedFilterV1 = filters.firstOrNull { filter ->
|
||||
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||
}
|
||||
updateTagMuteState(mutedFilterV1 != null)
|
||||
},
|
||||
{ throwable ->
|
||||
Log.e(TAG, "Error getting filters: $throwable")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.e(TAG, "Error getting filters: $throwable")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTagMuteState(muted: Boolean) {
|
||||
if (muted) {
|
||||
muteTagItem?.isVisible = false
|
||||
muteTagItem?.isEnabled = false
|
||||
unmuteTagItem?.isVisible = true
|
||||
} else {
|
||||
unmuteTagItem?.isVisible = false
|
||||
muteTagItem?.isEnabled = true
|
||||
muteTagItem?.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun muteTag(): Boolean {
|
||||
val tag = hashtag ?: return true
|
||||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.createFilter(
|
||||
tag,
|
||||
listOf(Filter.HOME),
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
title = "#$tag",
|
||||
context = listOf(FilterV1.HOME),
|
||||
filterAction = Filter.Action.WARN.action,
|
||||
expiresInSeconds = null
|
||||
).fold(
|
||||
{ filter ->
|
||||
mutedFilter = filter
|
||||
muteTagItem?.isVisible = false
|
||||
unmuteTagItem?.isVisible = true
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
|
||||
mutedFilter = filter
|
||||
updateTagMuteState(true)
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "Failed to mute #$tag")
|
||||
}
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "Failed to mute #$tag", it)
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
mastodonApi.createFilterV1(
|
||||
tag,
|
||||
listOf(FilterV1.HOME),
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
expiresInSeconds = null
|
||||
).fold(
|
||||
{ filter ->
|
||||
mutedFilterV1 = filter
|
||||
updateTagMuteState(true)
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "Failed to mute #$tag", throwable)
|
||||
}
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -225,19 +268,49 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
}
|
||||
|
||||
private fun unmuteTag(): Boolean {
|
||||
val filter = mutedFilter ?: return true
|
||||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.deleteFilter(filter.id).fold(
|
||||
val tag = hashtag
|
||||
val result = if (mutedFilter != null) {
|
||||
val filter = mutedFilter!!
|
||||
if (filter.context.size > 1) {
|
||||
// This filter exists in multiple contexts, just remove the home context
|
||||
mastodonApi.updateFilter(
|
||||
id = filter.id,
|
||||
context = filter.context.filter { it != Filter.Kind.HOME.kind }
|
||||
)
|
||||
} else {
|
||||
mastodonApi.deleteFilter(filter.id)
|
||||
}
|
||||
} else if (mutedFilterV1 != null) {
|
||||
mutedFilterV1?.let { filter ->
|
||||
if (filter.context.size > 1) {
|
||||
// This filter exists in multiple contexts, just remove the home context
|
||||
mastodonApi.updateFilterV1(
|
||||
id = filter.id,
|
||||
phrase = filter.phrase,
|
||||
context = filter.context.filter { it != FilterV1.HOME },
|
||||
irreversible = null,
|
||||
wholeWord = null,
|
||||
expiresInSeconds = null
|
||||
)
|
||||
} else {
|
||||
mastodonApi.deleteFilterV1(filter.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
result?.fold(
|
||||
{
|
||||
muteTagItem?.isVisible = true
|
||||
unmuteTagItem?.isVisible = false
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
updateTagMuteState(false)
|
||||
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
|
||||
mutedFilterV1 = null
|
||||
mutedFilter = null
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "Failed to unmute #${filter.phrase}", it)
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "Failed to unmute #$tag", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ 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.fragment.NotificationsFragment
|
||||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||
import java.util.Objects
|
||||
|
||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ const val NOTIFICATIONS = "Notifications"
|
|||
const val LOCAL = "Local"
|
||||
const val FEDERATED = "Federated"
|
||||
const val DIRECT = "Direct"
|
||||
const val TRENDING = "Trending"
|
||||
const val HASHTAG = "Hashtag"
|
||||
const val LIST = "List"
|
||||
|
||||
|
|
@ -41,55 +44,77 @@ data class TabData(
|
|||
val fragment: (List<String>) -> Fragment,
|
||||
val arguments: List<String> = emptyList(),
|
||||
val title: (Context) -> String = { context -> context.getString(text) }
|
||||
)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as TabData
|
||||
|
||||
if (id != other.id) return false
|
||||
if (arguments != other.arguments) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode() = Objects.hash(id, arguments)
|
||||
}
|
||||
|
||||
fun List<TabData>.hasTab(id: String): Boolean = this.find { it.id == id } != null
|
||||
|
||||
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
|
||||
return when (id) {
|
||||
HOME -> TabData(
|
||||
HOME,
|
||||
R.string.title_home,
|
||||
R.drawable.ic_home_24dp,
|
||||
{ TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) }
|
||||
id = HOME,
|
||||
text = R.string.title_home,
|
||||
icon = R.drawable.ic_home_24dp,
|
||||
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) }
|
||||
)
|
||||
NOTIFICATIONS -> TabData(
|
||||
NOTIFICATIONS,
|
||||
R.string.title_notifications,
|
||||
R.drawable.ic_notifications_24dp,
|
||||
{ NotificationsFragment.newInstance() }
|
||||
id = NOTIFICATIONS,
|
||||
text = R.string.title_notifications,
|
||||
icon = R.drawable.ic_notifications_24dp,
|
||||
fragment = { NotificationsFragment.newInstance() }
|
||||
)
|
||||
LOCAL -> TabData(
|
||||
LOCAL,
|
||||
R.string.title_public_local,
|
||||
R.drawable.ic_local_24dp,
|
||||
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) }
|
||||
id = LOCAL,
|
||||
text = R.string.title_public_local,
|
||||
icon = R.drawable.ic_local_24dp,
|
||||
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) }
|
||||
)
|
||||
FEDERATED -> TabData(
|
||||
FEDERATED,
|
||||
R.string.title_public_federated,
|
||||
R.drawable.ic_public_24dp,
|
||||
{ TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) }
|
||||
id = FEDERATED,
|
||||
text = R.string.title_public_federated,
|
||||
icon = R.drawable.ic_public_24dp,
|
||||
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) }
|
||||
)
|
||||
DIRECT -> TabData(
|
||||
DIRECT,
|
||||
R.string.title_direct_messages,
|
||||
R.drawable.ic_reblog_direct_24dp,
|
||||
{ ConversationsFragment.newInstance() }
|
||||
id = DIRECT,
|
||||
text = R.string.title_direct_messages,
|
||||
icon = R.drawable.ic_reblog_direct_24dp,
|
||||
fragment = { ConversationsFragment.newInstance() }
|
||||
)
|
||||
TRENDING -> TabData(
|
||||
id = TRENDING,
|
||||
text = R.string.title_public_trending_hashtags,
|
||||
icon = R.drawable.ic_trending_up_24px,
|
||||
fragment = { TrendingFragment.newInstance() }
|
||||
)
|
||||
HASHTAG -> TabData(
|
||||
HASHTAG,
|
||||
R.string.hashtags,
|
||||
R.drawable.ic_hashtag,
|
||||
{ args -> TimelineFragment.newHashtagInstance(args) },
|
||||
arguments,
|
||||
{ context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
|
||||
id = HASHTAG,
|
||||
text = R.string.hashtags,
|
||||
icon = R.drawable.ic_hashtag,
|
||||
fragment = { args -> TimelineFragment.newHashtagInstance(args) },
|
||||
arguments = arguments,
|
||||
title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
|
||||
)
|
||||
LIST -> TabData(
|
||||
LIST,
|
||||
R.string.list,
|
||||
R.drawable.ic_list,
|
||||
{ args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
|
||||
arguments,
|
||||
{ arguments.getOrNull(1).orEmpty() }
|
||||
id = LIST,
|
||||
text = R.string.list,
|
||||
icon = R.drawable.ic_list,
|
||||
fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
|
||||
arguments = arguments,
|
||||
title = { arguments.getOrNull(1).orEmpty() }
|
||||
)
|
||||
else -> throw IllegalArgumentException("unknown tab type")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,23 @@
|
|||
|
||||
package com.keylesspalace.tusky
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
|
|
@ -33,23 +40,27 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import androidx.transition.TransitionManager
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import com.keylesspalace.tusky.adapter.ItemInteractionListener
|
||||
import com.keylesspalace.tusky.adapter.ListSelectionAdapter
|
||||
import com.keylesspalace.tusky.adapter.TabAdapter
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.getDimension
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
|
|
@ -58,6 +69,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
|
|
@ -70,9 +82,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
private var tabsChanged = false
|
||||
|
||||
private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
|
||||
private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
|
||||
|
||||
private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
|
||||
private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
|
||||
|
||||
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
|
|
@ -160,7 +172,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
|
||||
override fun onTabAdded(tab: TabData) {
|
||||
|
||||
if (currentTabs.size >= MAX_TAB_COUNT) {
|
||||
return
|
||||
}
|
||||
|
|
@ -222,7 +233,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
|
||||
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
|
||||
|
||||
val frameLayout = FrameLayout(this)
|
||||
val padding = Utils.dpToPx(this, 8)
|
||||
frameLayout.updatePadding(left = padding, right = padding)
|
||||
|
|
@ -254,7 +264,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
.create()
|
||||
|
||||
editText.onTextChanged { s, _, _, _ ->
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
|
||||
}
|
||||
|
||||
|
|
@ -264,29 +274,80 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
|
||||
private fun showSelectListDialog() {
|
||||
val adapter = ListSelectionAdapter(this)
|
||||
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
getItem(position)?.let { item -> (view as TextView).text = item.title }
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
val statusLayout = LinearLayout(this)
|
||||
statusLayout.gravity = Gravity.CENTER
|
||||
val progress = ProgressBar(this)
|
||||
val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding)
|
||||
progress.setPadding(preferredPadding, 0, preferredPadding, 0)
|
||||
progress.visible(false)
|
||||
|
||||
val noListsText = TextView(this)
|
||||
noListsText.setPadding(preferredPadding, 0, preferredPadding, 0)
|
||||
noListsText.text = getText(R.string.select_list_empty)
|
||||
noListsText.visible(false)
|
||||
|
||||
statusLayout.addView(progress)
|
||||
statusLayout.addView(noListsText)
|
||||
|
||||
val dialogBuilder = AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_list_title)
|
||||
.setNeutralButton(R.string.select_list_manage) { _, _ ->
|
||||
val listIntent = Intent(applicationContext, ListsActivity::class.java)
|
||||
startActivity(listIntent)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setView(statusLayout)
|
||||
.setAdapter(adapter) { _, position ->
|
||||
adapter.getItem(position)?.let { item ->
|
||||
val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
}
|
||||
|
||||
val showProgressBarJob = getProgressBarJob(progress, 500)
|
||||
showProgressBarJob.start()
|
||||
|
||||
val dialog = dialogBuilder.show()
|
||||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.getLists().fold(
|
||||
{ lists ->
|
||||
showProgressBarJob.cancel()
|
||||
adapter.addAll(lists)
|
||||
if (lists.isEmpty()) {
|
||||
noListsText.show()
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
dialog.hide()
|
||||
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
|
||||
Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_list_title)
|
||||
.setAdapter(adapter) { _, position ->
|
||||
val list = adapter.getItem(position)
|
||||
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
.show()
|
||||
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
|
||||
start = CoroutineStart.LAZY
|
||||
) {
|
||||
try {
|
||||
delay(delayMs)
|
||||
progressView.show()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
progressView.hide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateHashtag(input: CharSequence?): Boolean {
|
||||
|
|
@ -317,6 +378,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
if (!currentTabs.contains(directMessagesTab)) {
|
||||
addableTabs.add(directMessagesTab)
|
||||
}
|
||||
val trendingTab = createTabDataFromId(TRENDING)
|
||||
if (!currentTabs.contains(trendingTab)) {
|
||||
addableTabs.add(trendingTab)
|
||||
}
|
||||
|
||||
addableTabs.add(createTabDataFromId(HASHTAG))
|
||||
addableTabs.add(createTabDataFromId(LIST))
|
||||
|
|
@ -337,13 +402,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
private fun saveTabs() {
|
||||
accountManager.activeAccount?.let {
|
||||
Single.fromCallable {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
it.tabPreferences = currentTabs
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe()
|
||||
}
|
||||
tabsChanged = true
|
||||
}
|
||||
|
|
@ -351,7 +413,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (tabsChanged) {
|
||||
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
|
||||
lifecycleScope.launch {
|
||||
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,15 +16,22 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import autodispose2.AutoDisposePlugins
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.di.AppInjector
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
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
|
||||
import com.keylesspalace.tusky.worker.WorkerFactory
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
|
||||
|
|
@ -33,19 +40,22 @@ import de.c1710.filemojicompat_ui.helpers.EmojiPreference
|
|||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.security.Security
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class TuskyApplication : Application(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
@Inject
|
||||
lateinit var notificationWorkerFactory: NotificationWorkerFactory
|
||||
lateinit var workerFactory: WorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var localeManager: LocaleManager
|
||||
|
||||
@Inject
|
||||
lateinit var sharedPreferences: SharedPreferences
|
||||
|
||||
override fun onCreate() {
|
||||
// Uncomment me to get StrictMode violation logs
|
||||
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
|
|
@ -65,7 +75,11 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
|
||||
AppInjector.init(this)
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
// Migrate shared preference keys and defaults from version to version.
|
||||
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
|
||||
if (oldVersion != SCHEMA_VERSION) {
|
||||
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
// In this case, we want to have the emoji preferences merged with the other ones
|
||||
// Copied from PreferenceManager.getDefaultSharedPreferenceName
|
||||
|
|
@ -73,7 +87,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
||||
|
||||
// init night mode
|
||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
setAppNightMode(theme)
|
||||
|
||||
localeManager.setLocale()
|
||||
|
|
@ -82,13 +96,45 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
Log.w("RxJava", "undeliverable exception", it)
|
||||
}
|
||||
|
||||
NotificationHelper.createWorkerNotificationChannel(this)
|
||||
|
||||
WorkManager.initialize(
|
||||
this,
|
||||
androidx.work.Configuration.Builder()
|
||||
.setWorkerFactory(notificationWorkerFactory)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Prune the database every ~ 12 hours when the device is idle.
|
||||
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
|
||||
.build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
PruneCacheWorker.PERIODIC_WORK_TAG,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
pruneCacheWorker
|
||||
)
|
||||
}
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
||||
private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) {
|
||||
Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion")
|
||||
val editor = sharedPreferences.edit()
|
||||
|
||||
if (oldVersion < 2023022701) {
|
||||
// These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity.
|
||||
|
||||
editor.remove(PrefKeys.ALWAYS_OPEN_SPOILER)
|
||||
editor.remove(PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA)
|
||||
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
|
||||
}
|
||||
|
||||
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TuskyApplication"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import android.webkit.MimeTypeMap
|
|||
import android.widget.Toast
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
|
@ -96,7 +97,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
supportPostponeEnterTransition()
|
||||
|
||||
// Gather the parameters.
|
||||
attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS)
|
||||
attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
|
||||
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
|
||||
|
||||
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
|
||||
|
|
@ -306,8 +307,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
isCreating = false
|
||||
invalidateOptionsMenu()
|
||||
binding.progressBarShare.visibility = View.GONE
|
||||
if (result)
|
||||
if (result) {
|
||||
shareFile(file, "image/png")
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
isCreating = false
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@
|
|||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
|
||||
import com.keylesspalace.tusky.entity.StringField
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.fixTextSelection
|
||||
|
||||
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
|
||||
|
||||
|
|
@ -81,25 +81,17 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
holder.binding.accountFieldValueTextLayout.counterMaxLength = it
|
||||
}
|
||||
|
||||
holder.binding.accountFieldNameText.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(newText: Editable) {
|
||||
fieldData[holder.bindingAdapterPosition].first = newText.toString()
|
||||
}
|
||||
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
|
||||
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
holder.binding.accountFieldValueText.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(newText: Editable) {
|
||||
fieldData[holder.bindingAdapterPosition].second = newText.toString()
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
|
||||
|
||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
// Ensure the textview contents are selectable
|
||||
holder.binding.accountFieldNameText.fixTextSelection()
|
||||
holder.binding.accountFieldValueText.fixTextSelection()
|
||||
}
|
||||
|
||||
class MutableStringPair(var first: String, var second: String)
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* 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.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
||||
/** Displays a list of blocked accounts. */
|
||||
class BlocksAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean,
|
||||
) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>(
|
||||
accountActionListener,
|
||||
animateAvatar,
|
||||
animateEmojis,
|
||||
showBotOverlay
|
||||
) {
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_blocked_user, parent, false)
|
||||
return BlockedUserViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: BlockedUserViewHolder, position: Int) {
|
||||
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
|
||||
viewHolder.setupActionListener(accountActionListener)
|
||||
}
|
||||
|
||||
class BlockedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val avatar: ImageView = itemView.findViewById(R.id.blocked_user_avatar)
|
||||
private val username: TextView = itemView.findViewById(R.id.blocked_user_username)
|
||||
private val displayName: TextView = itemView.findViewById(R.id.blocked_user_display_name)
|
||||
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
|
||||
private var id: String? = null
|
||||
|
||||
fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
|
||||
id = account.id
|
||||
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
|
||||
displayName.text = emojifiedName
|
||||
val format = username.context.getString(R.string.post_username_format)
|
||||
val formattedUsername = String.format(format, account.username)
|
||||
username.text = formattedUsername
|
||||
val avatarRadius = avatar.context.resources
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar)
|
||||
}
|
||||
|
||||
fun setupActionListener(listener: AccountActionListener) {
|
||||
unblock.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onBlock(false, id, position)
|
||||
}
|
||||
}
|
||||
itemView.setOnClickListener { listener.onViewAccount(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,18 +21,47 @@ 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
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
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
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
) : 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)
|
||||
}
|
||||
|
||||
fun setupWithAccount(
|
||||
account: TimelineAccount,
|
||||
|
|
@ -41,20 +70,41 @@ 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)
|
||||
val format = itemView.context.getString(R.string.post_username_format)
|
||||
val formattedUsername = String.format(format, account.username)
|
||||
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
|
||||
binding.usernameTextView.text = formattedUsername
|
||||
if (account.note.isEmpty()) {
|
||||
binding.accountNote.hide()
|
||||
} else {
|
||||
binding.accountNote.show()
|
||||
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml()
|
||||
.emojify(account.emojis, binding.accountNote, animateEmojis)
|
||||
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
|
||||
}
|
||||
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
|
||||
binding.avatarBadge.visible(showBotOverlay && account.bot)
|
||||
}
|
||||
|
||||
fun setupActionListener(listener: AccountActionListener, accountId: String) {
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
/* Copyright 2019 kyori19
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemPickerListBinding
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
|
||||
class ListSelectionAdapter(context: Context) : ArrayAdapter<MastoList>(context, R.layout.item_picker_list) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
|
||||
val binding = if (convertView == null) {
|
||||
ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
} else {
|
||||
ItemPickerListBinding.bind(convertView)
|
||||
}
|
||||
|
||||
getItem(position)?.let { list ->
|
||||
binding.root.text = list.title
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
}
|
||||
|
|
@ -1,691 +0,0 @@
|
|||
/* 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 {
|
||||
|
||||
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 String accountId;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private StatusActionListener statusListener;
|
||||
private NotificationActionListener notificationActionListener;
|
||||
private AccountActionListener accountActionListener;
|
||||
private 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, 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 payloads) {
|
||||
bindViewHolder(viewHolder, position, payloads);
|
||||
}
|
||||
|
||||
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List 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()
|
||||
);
|
||||
}
|
||||
|
||||
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 TextView message;
|
||||
private TextView usernameView;
|
||||
private TextView displayNameView;
|
||||
private ImageView avatar;
|
||||
private 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());
|
||||
|
||||
}
|
||||
|
||||
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 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 StatusDisplayOptions statusDisplayOptions;
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter;
|
||||
|
||||
private String accountId;
|
||||
private String notificationId;
|
||||
private NotificationActionListener notificationActionListener;
|
||||
private StatusViewData.Concrete statusViewData;
|
||||
|
||||
private int avatarRadius48dp;
|
||||
private int avatarRadius36dp;
|
||||
private 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);
|
||||
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.chinwag_green);
|
||||
format = context.getString(R.string.notification_reblog_format);
|
||||
break;
|
||||
}
|
||||
case STATUS: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.chinwag_green);
|
||||
format = context.getString(R.string.notification_subscription_format);
|
||||
break;
|
||||
}
|
||||
case UPDATE: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green);
|
||||
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());
|
||||
|
||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
Glide.with(notificationAvatar)
|
||||
.load(ContextCompat.getDrawable(notificationAvatar.getContext(), 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());
|
||||
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
||||
avatarRadius24dp, statusDisplayOptions.animateAvatars());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
switch (v.getId()) {
|
||||
case R.id.notification_container:
|
||||
case R.id.notification_content:
|
||||
if (notificationActionListener != null)
|
||||
notificationActionListener.onViewStatusForNotificationId(notificationId);
|
||||
break;
|
||||
case R.id.notification_top_text:
|
||||
if (notificationActionListener != null)
|
||||
notificationActionListener.onViewAccount(accountId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if (statusViewData.getSpoilerText() != null) {
|
||||
emojifiedContentWarning = CustomEmojiHelper.emojify(
|
||||
statusViewData.getSpoilerText(),
|
||||
statusViewData.getActionable().getEmojis(),
|
||||
contentWarningDescriptionTextView,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
} else {
|
||||
emojifiedContentWarning = "";
|
||||
}
|
||||
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,6 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
override fun getItemCount() = pollOptions.size
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemPollBinding>, position: Int) {
|
||||
|
||||
val option = pollOptions[position]
|
||||
|
||||
val resultTextView = holder.binding.statusPollOptionResult
|
||||
|
|
|
|||
|
|
@ -20,28 +20,76 @@ 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.adapter.NotificationsAdapter.NotificationActionListener
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
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,
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
private val notificationActionListener: NotificationActionListener
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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
|
||||
|
|
@ -52,17 +100,22 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
|
||||
private fun setupActionListener(
|
||||
listener: NotificationActionListener,
|
||||
reporteeId: String,
|
||||
reporterId: String,
|
||||
reportId: String
|
||||
) {
|
||||
binding.notificationReporteeAvatar.setOnClickListener {
|
||||
val position = bindingAdapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import android.graphics.drawable.Drawable;
|
|||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateUtils;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
|
|
@ -21,11 +22,10 @@ import android.widget.Toast;
|
|||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.PopupMenu;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
|
@ -44,6 +44,8 @@ import com.keylesspalace.tusky.entity.Attachment.Focus;
|
|||
import com.keylesspalace.tusky.entity.Attachment.MetaData;
|
||||
import com.keylesspalace.tusky.entity.Card;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
import com.keylesspalace.tusky.entity.Filter;
|
||||
import com.keylesspalace.tusky.entity.FilterResult;
|
||||
import com.keylesspalace.tusky.entity.HashTag;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
|
|
@ -53,6 +55,7 @@ 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.NumberUtils;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
import com.keylesspalace.tusky.util.TouchDelegateHelper;
|
||||
|
|
@ -76,46 +79,52 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
public static final String KEY_CREATED = "created";
|
||||
}
|
||||
|
||||
private TextView displayName;
|
||||
private TextView username;
|
||||
private ImageButton replyButton;
|
||||
private TextView replyCountLabel;
|
||||
private SparkButton reblogButton;
|
||||
private SparkButton favouriteButton;
|
||||
private SparkButton bookmarkButton;
|
||||
private ImageButton moreButton;
|
||||
private ConstraintLayout mediaContainer;
|
||||
protected MediaPreviewLayout mediaPreview;
|
||||
private TextView sensitiveMediaWarning;
|
||||
private View sensitiveMediaShow;
|
||||
protected TextView[] mediaLabels;
|
||||
protected CharSequence[] mediaDescriptions;
|
||||
private MaterialButton contentWarningButton;
|
||||
private ImageView avatarInset;
|
||||
private final String TAG = "StatusBaseViewHolder";
|
||||
|
||||
public ImageView avatar;
|
||||
public TextView metaInfo;
|
||||
public TextView content;
|
||||
public TextView contentWarningDescription;
|
||||
private final TextView displayName;
|
||||
private final TextView username;
|
||||
private final ImageButton replyButton;
|
||||
private final TextView replyCountLabel;
|
||||
private final SparkButton reblogButton;
|
||||
private final SparkButton favouriteButton;
|
||||
private final SparkButton bookmarkButton;
|
||||
private final ImageButton moreButton;
|
||||
private final ConstraintLayout mediaContainer;
|
||||
protected final MediaPreviewLayout mediaPreview;
|
||||
private final TextView sensitiveMediaWarning;
|
||||
private final View sensitiveMediaShow;
|
||||
protected final TextView[] mediaLabels;
|
||||
protected final CharSequence[] mediaDescriptions;
|
||||
private final MaterialButton contentWarningButton;
|
||||
private final ImageView avatarInset;
|
||||
|
||||
private RecyclerView pollOptions;
|
||||
private TextView pollDescription;
|
||||
private Button pollButton;
|
||||
public final ImageView avatar;
|
||||
public final TextView metaInfo;
|
||||
public final TextView content;
|
||||
public final TextView contentWarningDescription;
|
||||
|
||||
private LinearLayout cardView;
|
||||
private LinearLayout cardInfo;
|
||||
private ShapeableImageView cardImage;
|
||||
private TextView cardTitle;
|
||||
private TextView cardDescription;
|
||||
private TextView cardUrl;
|
||||
private PollAdapter pollAdapter;
|
||||
private final RecyclerView pollOptions;
|
||||
private final TextView pollDescription;
|
||||
private final Button pollButton;
|
||||
|
||||
private final LinearLayout cardView;
|
||||
private final LinearLayout cardInfo;
|
||||
private final ShapeableImageView cardImage;
|
||||
private final TextView cardTitle;
|
||||
private final TextView cardDescription;
|
||||
private final TextView cardUrl;
|
||||
private final PollAdapter pollAdapter;
|
||||
protected LinearLayout filteredPlaceholder;
|
||||
protected TextView filteredPlaceholderLabel;
|
||||
protected Button filteredPlaceholderShowButton;
|
||||
protected ConstraintLayout statusContainer;
|
||||
|
||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
||||
protected int avatarRadius48dp;
|
||||
private int avatarRadius36dp;
|
||||
private int avatarRadius24dp;
|
||||
protected final int avatarRadius48dp;
|
||||
private final int avatarRadius36dp;
|
||||
private final int avatarRadius24dp;
|
||||
|
||||
private final Drawable mediaPreviewUnloaded;
|
||||
|
||||
|
|
@ -161,6 +170,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardDescription = itemView.findViewById(R.id.card_description);
|
||||
cardUrl = itemView.findViewById(R.id.card_link);
|
||||
|
||||
filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder);
|
||||
filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label);
|
||||
filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway);
|
||||
statusContainer = itemView.findViewById(R.id.status_container);
|
||||
|
||||
pollAdapter = new PollAdapter();
|
||||
pollOptions.setAdapter(pollAdapter);
|
||||
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
||||
|
|
@ -192,16 +206,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
contentWarningButton.performClick();
|
||||
}
|
||||
|
||||
protected void setSpoilerAndContent(boolean expanded,
|
||||
@NonNull Spanned content,
|
||||
@Nullable String spoilerText,
|
||||
@Nullable List<Status.Mention> mentions,
|
||||
@Nullable List<HashTag> tags,
|
||||
@NonNull List<Emoji> emojis,
|
||||
@Nullable PollViewData poll,
|
||||
protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener) {
|
||||
|
||||
Status actionable = status.getActionable();
|
||||
String spoilerText = status.getSpoilerText();
|
||||
List<Emoji> emojis = actionable.getEmojis();
|
||||
|
||||
boolean sensitive = !TextUtils.isEmpty(spoilerText);
|
||||
boolean expanded = status.isExpanded();
|
||||
|
||||
if (sensitive) {
|
||||
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
|
||||
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
|
||||
|
|
@ -210,20 +225,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
contentWarningDescription.setVisibility(View.VISIBLE);
|
||||
contentWarningButton.setVisibility(View.VISIBLE);
|
||||
setContentWarningButtonText(expanded);
|
||||
contentWarningButton.setOnClickListener(view -> {
|
||||
contentWarningDescription.invalidate();
|
||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||
listener.onExpandedChange(!expanded, getBindingAdapterPosition());
|
||||
}
|
||||
setContentWarningButtonText(!expanded);
|
||||
|
||||
this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
|
||||
});
|
||||
this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
|
||||
contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
|
||||
this.setTextVisible(true, expanded, status, statusDisplayOptions, listener);
|
||||
} else {
|
||||
contentWarningDescription.setVisibility(View.GONE);
|
||||
contentWarningButton.setVisibility(View.GONE);
|
||||
this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
|
||||
this.setTextVisible(false, true, status, statusDisplayOptions, listener);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,20 +242,42 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
protected void toggleExpandedState(boolean sensitive,
|
||||
boolean expanded,
|
||||
@NonNull final StatusViewData.Concrete status,
|
||||
@NonNull final StatusDisplayOptions statusDisplayOptions,
|
||||
@NonNull final StatusActionListener listener) {
|
||||
|
||||
contentWarningDescription.invalidate();
|
||||
int adapterPosition = getBindingAdapterPosition();
|
||||
if (adapterPosition != RecyclerView.NO_POSITION) {
|
||||
listener.onExpandedChange(expanded, adapterPosition);
|
||||
}
|
||||
setContentWarningButtonText(expanded);
|
||||
|
||||
this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener);
|
||||
|
||||
setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
||||
}
|
||||
|
||||
private void setTextVisible(boolean sensitive,
|
||||
boolean expanded,
|
||||
Spanned content,
|
||||
List<Status.Mention> mentions,
|
||||
List<HashTag> tags,
|
||||
List<Emoji> emojis,
|
||||
@Nullable PollViewData poll,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
@NonNull final StatusViewData.Concrete status,
|
||||
@NonNull final StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener) {
|
||||
|
||||
Status actionable = status.getActionable();
|
||||
Spanned content = status.getContent();
|
||||
List<Status.Mention> mentions = actionable.getMentions();
|
||||
List<HashTag> tags =actionable.getTags();
|
||||
List<Emoji> emojis = actionable.getEmojis();
|
||||
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
|
||||
|
||||
if (expanded) {
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
||||
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
|
||||
for (int i = 0; i < mediaLabels.length; ++i) {
|
||||
updateMediaLabel(i, sensitive, expanded);
|
||||
updateMediaLabel(i, sensitive, true);
|
||||
}
|
||||
if (poll != null) {
|
||||
setupPoll(poll, emojis, statusDisplayOptions, listener);
|
||||
|
|
@ -273,7 +302,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
private void setAvatar(String url,
|
||||
@Nullable String rebloggedUrl,
|
||||
@Nullable String rebloggedUrl,
|
||||
boolean isBot,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
|
||||
|
|
@ -284,8 +313,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
avatarInset.setVisibility(View.VISIBLE);
|
||||
Glide.with(avatarInset)
|
||||
// passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692
|
||||
.load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge))
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(avatarInset);
|
||||
} else {
|
||||
avatarInset.setVisibility(View.GONE);
|
||||
|
|
@ -325,8 +353,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
} else {
|
||||
long then = createdAt.getTime();
|
||||
long now = System.currentTimeMillis();
|
||||
String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
|
||||
timestampText = readout;
|
||||
timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -365,11 +392,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
}
|
||||
|
||||
private void setReplyCount(int repliesCount) {
|
||||
protected void setReplyCount(int repliesCount, boolean fullStats) {
|
||||
// This label only exists in the non-detailed view (to match the web ui)
|
||||
if (replyCountLabel != null) {
|
||||
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
|
||||
if (replyCountLabel == null) return;
|
||||
|
||||
if (fullStats) {
|
||||
replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread
|
||||
// that they can click through to read.
|
||||
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
|
||||
}
|
||||
|
||||
private void setReblogged(boolean reblogged) {
|
||||
|
|
@ -598,9 +632,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
final String accountId,
|
||||
final String statusContent,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
View.OnClickListener profileButtonClickListener = button -> {
|
||||
listener.onViewAccount(accountId);
|
||||
};
|
||||
View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId);
|
||||
|
||||
avatar.setOnClickListener(profileButtonClickListener);
|
||||
displayName.setOnClickListener(profileButtonClickListener);
|
||||
|
|
@ -611,13 +643,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
listener.onReply(position);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (reblogButton != null) {
|
||||
reblogButton.setEventListener((button, buttonState) -> {
|
||||
// return true to play animation
|
||||
int position = getBindingAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
if (statusDisplayOptions.confirmReblogs()) {
|
||||
showConfirmReblogDialog(listener, statusContent, buttonState, position);
|
||||
showConfirmReblog(listener, buttonState, position);
|
||||
return false;
|
||||
} else {
|
||||
listener.onReblog(!buttonState, position);
|
||||
|
|
@ -629,12 +663,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
favouriteButton.setEventListener((button, buttonState) -> {
|
||||
// return true to play animation
|
||||
int position = getBindingAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
if (statusDisplayOptions.confirmFavourites()) {
|
||||
showConfirmFavouriteDialog(listener, statusContent, buttonState, position);
|
||||
showConfirmFavourite(listener, buttonState, position);
|
||||
return false;
|
||||
} else {
|
||||
listener.onFavourite(!buttonState, position);
|
||||
|
|
@ -673,38 +708,46 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
itemView.setOnClickListener(viewThreadListener);
|
||||
}
|
||||
|
||||
private void showConfirmReblogDialog(StatusActionListener listener,
|
||||
String statusContent,
|
||||
boolean buttonState,
|
||||
int position) {
|
||||
int okButtonTextId = buttonState ? R.string.action_unreblog : R.string.action_reblog;
|
||||
new AlertDialog.Builder(reblogButton.getContext())
|
||||
.setMessage(statusContent)
|
||||
.setPositiveButton(okButtonTextId, (__, ___) -> {
|
||||
listener.onReblog(!buttonState, position);
|
||||
if (!buttonState) {
|
||||
// Play animation only when it's reblog, not unreblog
|
||||
reblogButton.playAnimation();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
private void showConfirmReblog(StatusActionListener listener,
|
||||
boolean buttonState,
|
||||
int position) {
|
||||
PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton);
|
||||
popup.inflate(R.menu.status_reblog);
|
||||
Menu menu = popup.getMenu();
|
||||
if (buttonState) {
|
||||
menu.findItem(R.id.menu_action_reblog).setVisible(false);
|
||||
} else {
|
||||
menu.findItem(R.id.menu_action_unreblog).setVisible(false);
|
||||
}
|
||||
popup.setOnMenuItemClickListener(item -> {
|
||||
listener.onReblog(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
reblogButton.playAnimation();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
popup.show();
|
||||
}
|
||||
|
||||
private void showConfirmFavouriteDialog(StatusActionListener listener,
|
||||
String statusContent,
|
||||
boolean buttonState,
|
||||
int position) {
|
||||
int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite;
|
||||
new AlertDialog.Builder(favouriteButton.getContext())
|
||||
.setMessage(statusContent)
|
||||
.setPositiveButton(okButtonTextId, (__, ___) -> {
|
||||
listener.onFavourite(!buttonState, position);
|
||||
if (!buttonState) {
|
||||
// Play animation only when it's favourite, not unfavourite
|
||||
favouriteButton.playAnimation();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
private void showConfirmFavourite(StatusActionListener listener,
|
||||
boolean buttonState,
|
||||
int position) {
|
||||
PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton);
|
||||
popup.inflate(R.menu.status_favourite);
|
||||
Menu menu = popup.getMenu();
|
||||
if (buttonState) {
|
||||
menu.findItem(R.id.menu_action_favourite).setVisible(false);
|
||||
} else {
|
||||
menu.findItem(R.id.menu_action_unfavourite).setVisible(false);
|
||||
}
|
||||
popup.setOnMenuItemClickListener(item -> {
|
||||
listener.onFavourite(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
favouriteButton.playAnimation();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
popup.show();
|
||||
}
|
||||
|
||||
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
||||
|
|
@ -722,7 +765,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
setUsername(status.getUsername());
|
||||
setMetaData(status, statusDisplayOptions, listener);
|
||||
setIsReply(actionable.getInReplyToId() != null);
|
||||
setReplyCount(actionable.getRepliesCount());
|
||||
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
|
||||
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||
setReblogged(actionable.getReblogged());
|
||||
|
|
@ -747,18 +790,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
hideSensitiveMediaWarning();
|
||||
}
|
||||
|
||||
if (cardView != null) {
|
||||
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
||||
}
|
||||
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
||||
|
||||
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
||||
|
||||
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
|
||||
actionable.getMentions(), actionable.getTags(), actionable.getEmojis(),
|
||||
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
|
||||
listener);
|
||||
setSpoilerAndContent(status, statusDisplayOptions, listener);
|
||||
|
||||
setupFilterPlaceholder(status, listener, statusDisplayOptions);
|
||||
|
||||
setDescriptionForStatus(status, statusDisplayOptions);
|
||||
|
||||
|
|
@ -779,6 +819,30 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
|
||||
if (status.getFilterAction() != Filter.Action.WARN) {
|
||||
showFilteredPlaceholder(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showFilteredPlaceholder(true);
|
||||
|
||||
Filter matchedFilter = null;
|
||||
|
||||
for (FilterResult result : status.getActionable().getFiltered()) {
|
||||
Filter filter = result.getFilter();
|
||||
if (filter.getAction() == Filter.Action.WARN) {
|
||||
matchedFilter = filter;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
|
||||
filteredPlaceholderShowButton.setOnClickListener(view -> {
|
||||
listener.clearWarningAction(getBindingAdapterPosition());
|
||||
});
|
||||
}
|
||||
|
||||
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
|
||||
for (Attachment attachment : attachments) {
|
||||
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
|
||||
|
|
@ -1013,20 +1077,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected void setupCard(
|
||||
StatusViewData.Concrete status,
|
||||
CardViewMode cardViewMode,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusViewData.Concrete status,
|
||||
boolean expanded,
|
||||
final CardViewMode cardViewMode,
|
||||
final StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener
|
||||
) {
|
||||
if (cardView == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Status actionable = status.getActionable();
|
||||
final Card card = actionable.getCard();
|
||||
|
||||
if (cardViewMode != CardViewMode.NONE &&
|
||||
actionable.getAttachments().size() == 0 &&
|
||||
actionable.getPoll() == null &&
|
||||
card != null &&
|
||||
!TextUtils.isEmpty(card.getUrl()) &&
|
||||
(!actionable.getSensitive() || status.isExpanded()) &&
|
||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||
actionable.getAttachments().size() == 0 &&
|
||||
actionable.getPoll() == null &&
|
||||
card != null &&
|
||||
!TextUtils.isEmpty(card.getUrl()) &&
|
||||
(!actionable.getSensitive() || expanded) &&
|
||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||
|
||||
cardView.setVisibility(View.VISIBLE);
|
||||
cardTitle.setText(card.getTitle());
|
||||
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
|
||||
|
|
@ -1119,7 +1190,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardImage.setScaleType(ImageView.ScaleType.CENTER);
|
||||
|
||||
Glide.with(cardImage.getContext())
|
||||
.load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder))
|
||||
.load(R.drawable.card_image_placeholder)
|
||||
.into(cardImage);
|
||||
}
|
||||
|
||||
|
|
@ -1158,4 +1229,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
bookmarkButton.setVisibility(visibility);
|
||||
moreButton.setVisibility(visibility);
|
||||
}
|
||||
|
||||
public void showFilteredPlaceholder(boolean show) {
|
||||
if (statusContainer != null) {
|
||||
statusContainer.setVisibility(show ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
if (filteredPlaceholder != null) {
|
||||
filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
status;
|
||||
|
||||
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
||||
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
if (payloads == null) {
|
||||
Status actionable = uncollapsedStatus.getActionable();
|
||||
|
||||
|
|
|
|||
|
|
@ -28,9 +28,11 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
import com.keylesspalace.tusky.entity.Filter;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.NumberUtils;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
|
|
@ -44,13 +46,17 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||
|
||||
private TextView statusInfo;
|
||||
private Button contentCollapseButton;
|
||||
private final TextView statusInfo;
|
||||
private final Button contentCollapseButton;
|
||||
private final TextView favouritedCountLabel;
|
||||
private final TextView reblogsCountLabel;
|
||||
|
||||
public StatusViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
statusInfo = itemView.findViewById(R.id.status_info);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||
favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count);
|
||||
reblogsCountLabel = itemView.findViewById(R.id.status_insets);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -60,10 +66,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
@Nullable Object payloads) {
|
||||
if (payloads == null) {
|
||||
|
||||
setupCollapsedState(status, listener);
|
||||
boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText());
|
||||
boolean expanded = status.isExpanded();
|
||||
|
||||
setupCollapsedState(sensitive, expanded, status, listener);
|
||||
|
||||
Status reblogging = status.getRebloggingStatus();
|
||||
if (reblogging == null) {
|
||||
if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) {
|
||||
hideStatusInfo();
|
||||
} else {
|
||||
String rebloggedByDisplayName = reblogging.getAccount().getName();
|
||||
|
|
@ -73,8 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
}
|
||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||
|
||||
reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
|
||||
favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
|
||||
setFavouritedCount(status.getActionable().getFavouritesCount());
|
||||
setReblogsCount(status.getActionable().getReblogsCount());
|
||||
|
||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||
}
|
||||
|
||||
private void setRebloggedByDisplayName(final CharSequence name,
|
||||
|
|
@ -91,7 +105,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
|
||||
void setPollInfo(final boolean ownPoll) {
|
||||
protected void setPollInfo(final boolean ownPoll) {
|
||||
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
|
||||
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
|
||||
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
|
||||
|
|
@ -99,13 +113,24 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
statusInfo.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
void hideStatusInfo() {
|
||||
protected void setReblogsCount(int reblogsCount) {
|
||||
reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000));
|
||||
}
|
||||
|
||||
protected void setFavouritedCount(int favouritedCount) {
|
||||
favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000));
|
||||
}
|
||||
|
||||
protected void hideStatusInfo() {
|
||||
statusInfo.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void setupCollapsedState(final StatusViewData.Concrete status, final StatusActionListener listener) {
|
||||
private void setupCollapsedState(boolean sensitive,
|
||||
boolean expanded,
|
||||
final StatusViewData.Concrete status,
|
||||
final StatusActionListener listener) {
|
||||
/* input filter for TextViews have to be set before text */
|
||||
if (status.isCollapsible() && (status.isExpanded() || TextUtils.isEmpty(status.getSpoilerText()))) {
|
||||
if (status.isCollapsible() && (!sensitive || expanded)) {
|
||||
contentCollapseButton.setOnClickListener(view -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION)
|
||||
|
|
@ -130,4 +155,16 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
super.showStatusContent(show);
|
||||
contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void toggleExpandedState(boolean sensitive,
|
||||
boolean expanded,
|
||||
@NonNull StatusViewData.Concrete status,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
@NonNull final StatusActionListener listener) {
|
||||
|
||||
setupCollapsedState(sensitive, expanded, status, listener);
|
||||
|
||||
super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,45 +3,51 @@ package com.keylesspalace.tusky.appstore
|
|||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class CacheUpdater @Inject constructor(
|
||||
eventHub: EventHub,
|
||||
private val accountManager: AccountManager,
|
||||
accountManager: AccountManager,
|
||||
appDatabase: AppDatabase,
|
||||
gson: Gson
|
||||
) {
|
||||
|
||||
private val disposable: Disposable
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
init {
|
||||
val timelineDao = appDatabase.timelineDao()
|
||||
|
||||
disposable = eventHub.events.subscribe { event ->
|
||||
val accountId = accountManager.activeAccount?.id ?: return@subscribe
|
||||
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 UnfollowEvent ->
|
||||
timelineDao.removeAllByUser(accountId, event.accountId)
|
||||
is StatusDeletedEvent ->
|
||||
timelineDao.delete(accountId, event.statusId)
|
||||
is PollVoteEvent -> {
|
||||
val pollString = gson.toJson(event.poll)
|
||||
timelineDao.setVoted(accountId, event.statusId, pollString)
|
||||
scope.launch {
|
||||
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 UnfollowEvent ->
|
||||
timelineDao.removeAllByUser(accountId, event.accountId)
|
||||
is StatusDeletedEvent ->
|
||||
timelineDao.delete(accountId, event.statusId)
|
||||
is PollVoteEvent -> {
|
||||
val pollString = gson.toJson(event.poll)
|
||||
timelineDao.setVoted(accountId, event.statusId, pollString)
|
||||
}
|
||||
is PinEvent ->
|
||||
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
||||
}
|
||||
is PinEvent ->
|
||||
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
this.disposable.dispose()
|
||||
this.scope.cancel()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,21 @@ import com.keylesspalace.tusky.entity.Account
|
|||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
||||
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
|
||||
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable
|
||||
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Dispatchable
|
||||
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Dispatchable
|
||||
data class UnfollowEvent(val accountId: String) : Dispatchable
|
||||
data class BlockEvent(val accountId: String) : Dispatchable
|
||||
data class MuteEvent(val accountId: String) : Dispatchable
|
||||
data class StatusDeletedEvent(val statusId: String) : Dispatchable
|
||||
data class StatusComposedEvent(val status: Status) : Dispatchable
|
||||
data class StatusScheduledEvent(val status: Status) : Dispatchable
|
||||
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
|
||||
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
|
||||
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
|
||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
|
||||
data class DomainMuteEvent(val instance: String) : Dispatchable
|
||||
data class AnnouncementReadEvent(val announcementId: String) : Dispatchable
|
||||
data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable
|
||||
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 MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
|
||||
data class UnfollowEvent(val accountId: String) : Event
|
||||
data class BlockEvent(val accountId: String) : Event
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
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
|
||||
import javax.inject.Singleton
|
||||
|
||||
interface Event
|
||||
interface Dispatchable : Event
|
||||
|
||||
@Singleton
|
||||
class EventHub @Inject constructor() {
|
||||
|
||||
private val eventsSubject = PublishSubject.create<Event>()
|
||||
val events: Observable<Event> = eventsSubject
|
||||
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
||||
val events: Flow<Event> = sharedEventFlow
|
||||
|
||||
fun dispatch(event: Dispatchable) {
|
||||
eventsSubject.onNext(event)
|
||||
suspend fun dispatch(event: Event) {
|
||||
sharedEventFlow.emit(event)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
|
@ -32,12 +34,15 @@ import androidx.activity.viewModels
|
|||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Px
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
|
|
@ -50,13 +55,13 @@ import com.google.android.material.shape.ShapeAppearanceModel
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.keylesspalace.tusky.AccountListActivity
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.EditProfileActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
|
||||
|
|
@ -70,7 +75,6 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
|||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.DefaultTextWatcher
|
||||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
|
|
@ -82,9 +86,14 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
|||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
||||
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 dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import java.text.NumberFormat
|
||||
|
|
@ -94,12 +103,14 @@ import java.util.Locale
|
|||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener {
|
||||
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var draftsAlert: DraftsAlert
|
||||
|
||||
|
|
@ -109,7 +120,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
|
||||
private lateinit var accountFieldAdapter: AccountFieldAdapter
|
||||
|
||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
private var followState: FollowState = FollowState.NOT_FOLLOWING
|
||||
private var blocking: Boolean = false
|
||||
|
|
@ -125,14 +136,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
// fields for scroll animation
|
||||
private var hideFab: Boolean = false
|
||||
private var oldOffset: Int = 0
|
||||
|
||||
@ColorInt
|
||||
private var toolbarColor: Int = 0
|
||||
|
||||
@ColorInt
|
||||
private var statusBarColorTransparent: Int = 0
|
||||
|
||||
@ColorInt
|
||||
private var statusBarColorOpaque: Int = 0
|
||||
|
||||
private var avatarSize: Float = 0f
|
||||
|
||||
@Px
|
||||
private var titleVisibleHeight: Int = 0
|
||||
private lateinit var domain: String
|
||||
|
|
@ -145,11 +160,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
|
||||
private lateinit var adapter: AccountPagerAdapter
|
||||
|
||||
private var noteWatcher: TextWatcher? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
loadResources()
|
||||
makeNotificationBarTransparent()
|
||||
setContentView(binding.root)
|
||||
addMenuProvider(this)
|
||||
|
||||
// Obtain information to fill out the profile.
|
||||
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
|
||||
|
|
@ -178,9 +196,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
* Load colors and dimensions from resources
|
||||
*/
|
||||
private fun loadResources() {
|
||||
toolbarColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK)
|
||||
toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
|
||||
statusBarColorOpaque = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.BLACK)
|
||||
statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK)
|
||||
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
|
||||
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
|
||||
}
|
||||
|
|
@ -298,6 +316,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
binding.accountToolbar.background = toolbarBackground
|
||||
|
||||
// Provide a non-transparent background to the navigation and overflow icons to ensure
|
||||
// they remain visible over whatever the profile background image might be.
|
||||
val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!!
|
||||
backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere
|
||||
binding.accountToolbar.navigationIcon = LayerDrawable(
|
||||
arrayOf(
|
||||
backgroundCircle,
|
||||
binding.accountToolbar.navigationIcon
|
||||
)
|
||||
)
|
||||
binding.accountToolbar.overflowIcon = LayerDrawable(
|
||||
arrayOf(
|
||||
backgroundCircle,
|
||||
binding.accountToolbar.overflowIcon
|
||||
)
|
||||
)
|
||||
|
||||
binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
|
||||
|
||||
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply {
|
||||
|
|
@ -313,7 +348,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {
|
||||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
|
||||
if (verticalOffset == oldOffset) {
|
||||
return
|
||||
}
|
||||
|
|
@ -394,14 +428,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
draftsAlert.observeInContext(this, true)
|
||||
}
|
||||
|
||||
private fun onRefresh() {
|
||||
viewModel.refresh()
|
||||
adapter.refreshContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup swipe to refresh layout
|
||||
*/
|
||||
private fun setupRefreshLayout() {
|
||||
binding.swipeToRefreshLayout.setOnRefreshListener {
|
||||
viewModel.refresh()
|
||||
adapter.refreshContent()
|
||||
}
|
||||
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
|
||||
viewModel.isRefreshing.observe(
|
||||
this
|
||||
) { isRefreshing ->
|
||||
|
|
@ -434,8 +470,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
|
||||
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
|
||||
|
||||
accountFieldAdapter.fields = account.fields ?: emptyList()
|
||||
accountFieldAdapter.emojis = account.emojis ?: emptyList()
|
||||
accountFieldAdapter.fields = account.fields.orEmpty()
|
||||
accountFieldAdapter.emojis = account.emojis.orEmpty()
|
||||
accountFieldAdapter.notifyDataSetChanged()
|
||||
|
||||
binding.accountLockedImageView.visible(account.locked)
|
||||
|
|
@ -488,18 +524,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
.centerCrop()
|
||||
.into(binding.accountHeaderImageView)
|
||||
|
||||
binding.accountAvatarImageView.setOnClickListener { avatarView ->
|
||||
val intent =
|
||||
ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
|
||||
|
||||
avatarView.transitionName = account.avatar
|
||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar)
|
||||
|
||||
startActivity(intent, options.toBundle())
|
||||
binding.accountAvatarImageView.setOnClickListener { view ->
|
||||
viewImage(view, account.avatar)
|
||||
}
|
||||
binding.accountHeaderImageView.setOnClickListener { view ->
|
||||
viewImage(view, account.header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewImage(view: View, uri: String) {
|
||||
view.transitionName = uri
|
||||
startActivity(
|
||||
ViewMediaActivity.newSingleImageIntent(view.context, uri),
|
||||
ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update toolbar views for loaded account
|
||||
*/
|
||||
|
|
@ -614,10 +655,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
binding.accountSubscribeButton.setOnClickListener {
|
||||
viewModel.changeSubscribingState()
|
||||
}
|
||||
if (relation.notifying != null)
|
||||
if (relation.notifying != null) {
|
||||
subscribing = relation.notifying
|
||||
else if (relation.subscribing != null)
|
||||
} else if (relation.subscribing != null) {
|
||||
subscribing = relation.subscribing
|
||||
}
|
||||
}
|
||||
|
||||
// remove the listener so it doesn't fire on non-user changes
|
||||
|
|
@ -626,15 +668,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
binding.accountNoteTextInputLayout.visible(relation.note != null)
|
||||
binding.accountNoteTextInputLayout.editText?.setText(relation.note)
|
||||
|
||||
binding.accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher)
|
||||
|
||||
updateButtons()
|
||||
}
|
||||
|
||||
private val noteWatcher = object : DefaultTextWatcher() {
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
noteWatcher = binding.accountNoteTextInputLayout.editText?.doAfterTextChanged { s ->
|
||||
viewModel.noteChanged(s.toString())
|
||||
}
|
||||
|
||||
updateButtons()
|
||||
}
|
||||
|
||||
private fun updateFollowButton() {
|
||||
|
|
@ -685,7 +723,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
invalidateOptionsMenu()
|
||||
|
||||
if (loadedAccount?.moved == null) {
|
||||
|
||||
binding.accountFollowButton.show()
|
||||
updateFollowButton()
|
||||
updateSubscribeButton()
|
||||
|
|
@ -706,7 +743,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.account_toolbar, menu)
|
||||
|
||||
val openAsItem = menu.findItem(R.id.action_open_as)
|
||||
|
|
@ -718,7 +755,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
}
|
||||
|
||||
if (!viewModel.isSelf) {
|
||||
|
||||
val block = menu.findItem(R.id.action_block)
|
||||
block.title = if (blocking) {
|
||||
getString(R.string.action_unblock)
|
||||
|
|
@ -771,7 +807,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
menu.removeItem(R.id.action_add_or_remove_from_list)
|
||||
}
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
menu.findItem(R.id.action_search)?.apply {
|
||||
icon = IconicsDrawable(this@AccountActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||
sizeDp = 20
|
||||
colorInt = MaterialColors.getColor(binding.collapsingToolbar, android.R.attr.textColorPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFollowRequestPendingDialog() {
|
||||
|
|
@ -859,7 +900,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
viewUrl(url)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_open_in_web -> {
|
||||
// If the account isn't loaded yet, eat the input.
|
||||
|
|
@ -871,7 +912,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
R.id.action_open_as -> {
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
showAccountChooserDialog(
|
||||
item.title, false,
|
||||
item.title,
|
||||
false,
|
||||
object : AccountSelectionListener {
|
||||
override fun onAccountSelected(account: AccountEntity) {
|
||||
openAsAccount(loadedAccount.url, account)
|
||||
|
|
@ -924,6 +966,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
viewModel.changeShowReblogsState()
|
||||
return true
|
||||
}
|
||||
R.id.action_refresh -> {
|
||||
binding.swipeToRefreshLayout.isRefreshing = true
|
||||
onRefresh()
|
||||
return true
|
||||
}
|
||||
R.id.action_report -> {
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
|
||||
|
|
@ -931,23 +978,25 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getActionButton(): FloatingActionButton? {
|
||||
return if (!blocking) {
|
||||
binding.accountFloatingActionButton
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFullUsername(account: Account): String {
|
||||
if (account.isRemote()) {
|
||||
return "@" + account.username
|
||||
return if (account.isRemote()) {
|
||||
"@" + account.username
|
||||
} else {
|
||||
val localUsername = account.localUsername
|
||||
// Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible.
|
||||
val domain = accountManager.activeAccount!!.domain
|
||||
return "@$localUsername@$domain"
|
||||
"@$localUsername@$domain"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package com.keylesspalace.tusky.components.account
|
|||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
|
|
@ -16,22 +18,17 @@ import com.keylesspalace.tusky.network.MastodonApi
|
|||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val accountManager: AccountManager
|
||||
) : RxAwareViewModel() {
|
||||
) : ViewModel() {
|
||||
|
||||
val accountData = MutableLiveData<Resource<Account>>()
|
||||
val relationshipData = MutableLiveData<Resource<Relationship>>()
|
||||
|
|
@ -44,15 +41,16 @@ class AccountViewModel @Inject constructor(
|
|||
lateinit var accountId: String
|
||||
var isSelf = false
|
||||
|
||||
private var noteDisposable: Disposable? = null
|
||||
private var noteUpdateJob: Job? = null
|
||||
|
||||
init {
|
||||
eventHub.events
|
||||
.subscribe { event ->
|
||||
viewModelScope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
|
||||
accountData.postValue(Success(event.newProfileData))
|
||||
}
|
||||
}.autoDispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainAccount(reload: Boolean = false) {
|
||||
|
|
@ -60,40 +58,41 @@ class AccountViewModel @Inject constructor(
|
|||
isDataLoading = true
|
||||
accountData.postValue(Loading())
|
||||
|
||||
mastodonApi.account(accountId)
|
||||
.subscribe(
|
||||
{ account ->
|
||||
accountData.postValue(Success(account))
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining account", t)
|
||||
accountData.postValue(Error())
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
viewModelScope.launch {
|
||||
mastodonApi.account(accountId)
|
||||
.fold(
|
||||
{ account ->
|
||||
accountData.postValue(Success(account))
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining account", t)
|
||||
accountData.postValue(Error(cause = t))
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainRelationship(reload: Boolean = false) {
|
||||
if (relationshipData.value == null || reload) {
|
||||
|
||||
relationshipData.postValue(Loading())
|
||||
|
||||
mastodonApi.relationships(listOf(accountId))
|
||||
.subscribe(
|
||||
{ relationships ->
|
||||
relationshipData.postValue(Success(relationships[0]))
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining relationships", t)
|
||||
relationshipData.postValue(Error())
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
viewModelScope.launch {
|
||||
mastodonApi.relationships(listOf(accountId))
|
||||
.fold(
|
||||
{ relationships ->
|
||||
relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error())
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining relationships", t)
|
||||
relationshipData.postValue(Error(cause = t))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,42 +133,30 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun blockDomain(instance: String) {
|
||||
mastodonApi.blockDomain(instance).enqueue(object : Callback<Any> {
|
||||
override fun onResponse(call: Call<Any>, response: Response<Any>) {
|
||||
if (response.isSuccessful) {
|
||||
eventHub.dispatch(DomainMuteEvent(instance))
|
||||
val relation = relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Error muting %s".format(instance))
|
||||
viewModelScope.launch {
|
||||
mastodonApi.blockDomain(instance).fold({
|
||||
eventHub.dispatch(DomainMuteEvent(instance))
|
||||
val relation = relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Any>, t: Throwable) {
|
||||
Log.e(TAG, "Error muting %s".format(instance), t)
|
||||
}
|
||||
})
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error muting $instance", e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun unblockDomain(instance: String) {
|
||||
mastodonApi.unblockDomain(instance).enqueue(object : Callback<Any> {
|
||||
override fun onResponse(call: Call<Any>, response: Response<Any>) {
|
||||
if (response.isSuccessful) {
|
||||
val relation = relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Error unmuting %s".format(instance))
|
||||
viewModelScope.launch {
|
||||
mastodonApi.unblockDomain(instance).fold({
|
||||
val relation = relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Any>, t: Throwable) {
|
||||
Log.e(TAG, "Error unmuting %s".format(instance), t)
|
||||
}
|
||||
})
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error unmuting $instance", e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun changeShowReblogsState() {
|
||||
|
|
@ -209,84 +196,88 @@ class AccountViewModel @Inject constructor(
|
|||
RelationShipAction.MUTE -> relation.copy(muting = true)
|
||||
RelationShipAction.UNMUTE -> relation.copy(muting = false)
|
||||
RelationShipAction.SUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
if (isMastodon) {
|
||||
relation.copy(notifying = true)
|
||||
else relation.copy(subscribing = true)
|
||||
} else {
|
||||
relation.copy(subscribing = true)
|
||||
}
|
||||
}
|
||||
RelationShipAction.UNSUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
if (isMastodon) {
|
||||
relation.copy(notifying = false)
|
||||
else relation.copy(subscribing = false)
|
||||
} else {
|
||||
relation.copy(subscribing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
relationshipData.postValue(Loading(newRelation))
|
||||
}
|
||||
|
||||
try {
|
||||
val relationship = when (relationshipAction) {
|
||||
RelationShipAction.FOLLOW -> mastodonApi.followAccount(
|
||||
accountId,
|
||||
showReblogs = parameter ?: true
|
||||
)
|
||||
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
|
||||
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
|
||||
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
|
||||
RelationShipAction.MUTE -> mastodonApi.muteAccount(
|
||||
accountId,
|
||||
parameter ?: true,
|
||||
duration
|
||||
)
|
||||
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
|
||||
RelationShipAction.SUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
mastodonApi.followAccount(accountId, notify = true)
|
||||
else mastodonApi.subscribeAccount(accountId)
|
||||
}
|
||||
RelationShipAction.UNSUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
mastodonApi.followAccount(accountId, notify = false)
|
||||
else mastodonApi.unsubscribeAccount(accountId)
|
||||
val relationshipCall = when (relationshipAction) {
|
||||
RelationShipAction.FOLLOW -> mastodonApi.followAccount(
|
||||
accountId,
|
||||
showReblogs = parameter ?: true
|
||||
)
|
||||
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
|
||||
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
|
||||
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
|
||||
RelationShipAction.MUTE -> mastodonApi.muteAccount(
|
||||
accountId,
|
||||
parameter ?: true,
|
||||
duration
|
||||
)
|
||||
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
|
||||
RelationShipAction.SUBSCRIBE -> {
|
||||
if (isMastodon) {
|
||||
mastodonApi.followAccount(accountId, notify = true)
|
||||
} else {
|
||||
mastodonApi.subscribeAccount(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
relationshipData.postValue(Success(relationship))
|
||||
|
||||
when (relationshipAction) {
|
||||
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
||||
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
|
||||
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
|
||||
else -> {
|
||||
RelationShipAction.UNSUBSCRIBE -> {
|
||||
if (isMastodon) {
|
||||
mastodonApi.followAccount(accountId, notify = false)
|
||||
} else {
|
||||
mastodonApi.unsubscribeAccount(accountId)
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
relationshipData.postValue(Error(relation))
|
||||
}
|
||||
|
||||
relationshipCall.fold(
|
||||
{ relationship ->
|
||||
relationshipData.postValue(Success(relationship))
|
||||
|
||||
when (relationshipAction) {
|
||||
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
||||
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
|
||||
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
|
||||
else -> { }
|
||||
}
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed loading relationship", t)
|
||||
relationshipData.postValue(Error(relation, cause = t))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun noteChanged(newNote: String) {
|
||||
noteSaved.postValue(false)
|
||||
noteDisposable?.dispose()
|
||||
noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS)
|
||||
.flatMap {
|
||||
mastodonApi.updateAccountNote(accountId, newNote)
|
||||
}
|
||||
.doOnSuccess {
|
||||
noteSaved.postValue(true)
|
||||
}
|
||||
.delay(4, TimeUnit.SECONDS)
|
||||
.subscribe(
|
||||
{
|
||||
noteSaved.postValue(false)
|
||||
},
|
||||
{
|
||||
Log.e(TAG, "Error updating note", it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
noteDisposable?.dispose()
|
||||
noteUpdateJob?.cancel()
|
||||
noteUpdateJob = viewModelScope.launch {
|
||||
delay(1500)
|
||||
mastodonApi.updateAccountNote(accountId, newNote)
|
||||
.fold(
|
||||
{
|
||||
noteSaved.postValue(true)
|
||||
delay(4000)
|
||||
noteSaved.postValue(false)
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "Error updating note", t)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
|
@ -294,12 +285,14 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun reload(isReload: Boolean = false) {
|
||||
if (isDataLoading)
|
||||
if (isDataLoading) {
|
||||
return
|
||||
}
|
||||
accountId.let {
|
||||
obtainAccount(isReload)
|
||||
if (!isSelf)
|
||||
if (!isSelf) {
|
||||
obtainRelationship(isReload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import com.keylesspalace.tusky.util.viewBinding
|
|||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class ListsForAccountFragment : DialogFragment(), Injectable {
|
||||
|
|
@ -65,7 +64,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
|
|||
dialog?.apply {
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -103,16 +102,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
|
|||
binding.listsView.hide()
|
||||
binding.messageView.apply {
|
||||
show()
|
||||
|
||||
if (error is IOException) {
|
||||
setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
load()
|
||||
}
|
||||
} else {
|
||||
setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
load()
|
||||
}
|
||||
}
|
||||
setup(error) { load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -172,7 +162,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
|
|||
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAddOrRemoveFromListBinding> {
|
||||
val binding =
|
||||
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
|
|
|
|||
|
|
@ -35,23 +35,23 @@ import javax.inject.Inject
|
|||
|
||||
data class AccountListState(
|
||||
val list: MastoList,
|
||||
val includesAccount: Boolean,
|
||||
val includesAccount: Boolean
|
||||
)
|
||||
|
||||
data class ActionError(
|
||||
val error: Throwable,
|
||||
val type: Type,
|
||||
val listId: String,
|
||||
val listId: String
|
||||
) : Throwable(error) {
|
||||
enum class Type {
|
||||
ADD,
|
||||
REMOVE,
|
||||
REMOVE
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ListsForAccountViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val mastodonApi: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
private lateinit var accountId: String
|
||||
|
|
@ -75,14 +75,14 @@ class ListsForAccountViewModel @Inject constructor(
|
|||
runCatching {
|
||||
val (all, includes) = listOf(
|
||||
async { mastodonApi.getLists() },
|
||||
async { mastodonApi.getListsIncludesAccount(accountId) },
|
||||
async { mastodonApi.getListsIncludesAccount(accountId) }
|
||||
).awaitAll()
|
||||
|
||||
_states.emit(
|
||||
all.getOrThrow().map { list ->
|
||||
AccountListState(
|
||||
list = list,
|
||||
includesAccount = includes.getOrThrow().any { it.id == list.id },
|
||||
includesAccount = includes.getOrThrow().any { it.id == list.id }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,15 +16,21 @@
|
|||
package com.keylesspalace.tusky.components.account.media
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||
|
|
@ -39,20 +45,21 @@ import com.keylesspalace.tusky.util.openLink
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
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.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Created by charlag on 26/10/2017.
|
||||
*
|
||||
* Fragment with multiple columns of media previews for the specified account.
|
||||
*/
|
||||
|
||||
class AccountMediaFragment :
|
||||
Fragment(R.layout.fragment_timeline),
|
||||
RefreshableFragment,
|
||||
MenuProvider,
|
||||
Injectable {
|
||||
|
||||
@Inject
|
||||
|
|
@ -73,6 +80,7 @@ class AccountMediaFragment :
|
|||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
|
||||
|
|
@ -95,6 +103,8 @@ class AccountMediaFragment :
|
|||
binding.recyclerView.adapter = adapter
|
||||
|
||||
binding.swipeRefreshLayout.isEnabled = false
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
|
||||
binding.statusView.visibility = View.GONE
|
||||
|
||||
|
|
@ -108,6 +118,10 @@ class AccountMediaFragment :
|
|||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
when (loadState.refresh) {
|
||||
is LoadState.NotLoading -> {
|
||||
|
|
@ -118,12 +132,7 @@ class AccountMediaFragment :
|
|||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||
}
|
||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error)
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
|
|
@ -133,6 +142,27 @@ class AccountMediaFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.fragment_account_media, 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
|
||||
refreshContent()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAttachmentClick(selected: AttachmentViewData, view: View) {
|
||||
if (!selected.isRevealed) {
|
||||
viewModel.revealAttachment(selected)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class AccountMediaGridAdapter(
|
|||
}
|
||||
) {
|
||||
|
||||
private val baseItemBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK)
|
||||
private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||
private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
|
||||
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ class AccountMediaPagingSource(
|
|||
override fun getRefreshKey(state: PagingState<String, AttachmentViewData>): String? = null
|
||||
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, AttachmentViewData> {
|
||||
|
||||
return if (params is LoadParams.Refresh) {
|
||||
val list = viewModel.attachmentData.toList()
|
||||
LoadResult.Page(list, null, list.lastOrNull()?.statusId)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ class AccountMediaRemoteMediator(
|
|||
loadType: LoadType,
|
||||
state: PagingState<String, AttachmentViewData>
|
||||
): MediatorResult {
|
||||
|
||||
try {
|
||||
val statusResponse = when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import com.keylesspalace.tusky.network.MastodonApi
|
|||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountMediaViewModel @Inject constructor (
|
||||
class AccountMediaViewModel @Inject constructor(
|
||||
api: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
|
|||
|
|
@ -13,18 +13,20 @@
|
|||
* 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
|
||||
package com.keylesspalace.tusky.components.accountlist
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.commit
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
|
||||
import com.keylesspalace.tusky.fragment.AccountListFragment
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountListActivity : BaseActivity(), HasAndroidInjector {
|
||||
class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
|
@ -63,10 +65,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
|
|||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
|
||||
.commit()
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
|
@ -76,8 +77,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
|
|||
private const val EXTRA_ID = "id"
|
||||
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
|
||||
return Intent(context, AccountListActivity::class.java).apply {
|
||||
putExtra(EXTRA_TYPE, type)
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
* 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.fragment
|
||||
package com.keylesspalace.tusky.components.accountlist
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
|
|
@ -27,25 +27,30 @@ import androidx.recyclerview.widget.DividerItemDecoration
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
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.AccountListActivity.Type
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.AccountAdapter
|
||||
import com.keylesspalace.tusky.adapter.BlocksAdapter
|
||||
import com.keylesspalace.tusky.adapter.FollowAdapter
|
||||
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter
|
||||
import com.keylesspalace.tusky.adapter.FollowRequestsHeaderAdapter
|
||||
import com.keylesspalace.tusky.adapter.MutesAdapter
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
|
||||
import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter
|
||||
import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter
|
||||
import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter
|
||||
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter
|
||||
import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHeaderAdapter
|
||||
import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter
|
||||
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
|
|
@ -59,10 +64,15 @@ import retrofit2.Response
|
|||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable {
|
||||
class AccountListFragment :
|
||||
Fragment(R.layout.fragment_account_list),
|
||||
AccountActionListener,
|
||||
LinkListener,
|
||||
Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
|
|
@ -83,15 +93,15 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
val layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
|
||||
val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
|
|
@ -101,8 +111,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.FOLLOW_REQUESTS -> {
|
||||
val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true)
|
||||
val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
val headerAdapter = FollowRequestsHeaderAdapter(
|
||||
instanceName = accountManager.activeAccount!!.domain,
|
||||
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
|
||||
)
|
||||
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
|
||||
followRequestsAdapter
|
||||
}
|
||||
|
|
@ -126,6 +139,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
fetchAccounts()
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
(activity as BaseActivity?)
|
||||
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
(activity as BaseActivity?)?.let {
|
||||
val intent = AccountActivity.getIntent(it, id)
|
||||
|
|
@ -133,6 +151,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
}
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
(activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
|
||||
}
|
||||
|
||||
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
|
|
@ -225,7 +247,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
accountId: String,
|
||||
position: Int
|
||||
) {
|
||||
|
||||
if (accept) {
|
||||
api.authorizeFollowRequest(accountId)
|
||||
} else {
|
||||
|
|
@ -285,6 +306,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
return
|
||||
}
|
||||
fetching = true
|
||||
binding.swipeRefreshLayout.isRefreshing = true
|
||||
|
||||
if (fromId != null) {
|
||||
binding.recyclerView.post { adapter.setBottomLoading(true) }
|
||||
|
|
@ -293,6 +315,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val response = getFetchCallByListType(fromId)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
onFetchAccountsFailure(Exception(response.message()))
|
||||
return@launch
|
||||
|
|
@ -315,6 +338,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
|
||||
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
|
||||
adapter.setBottomLoading(false)
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
val next = HttpHeaderLink.findByRelationType(links, "next")
|
||||
|
|
@ -347,12 +371,12 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
}
|
||||
|
||||
private fun fetchRelationships(ids: List<String>) {
|
||||
api.relationships(ids)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe(::onFetchRelationshipsSuccess) {
|
||||
onFetchRelationshipsFailure(ids)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
api.relationships(ids)
|
||||
.fold(::onFetchRelationshipsSuccess) { throwable ->
|
||||
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
|
||||
|
|
@ -362,26 +386,16 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
|
||||
}
|
||||
|
||||
private fun onFetchRelationshipsFailure(ids: List<String>) {
|
||||
Log.e(TAG, "Fetch failure for relationships of accounts: $ids")
|
||||
}
|
||||
|
||||
private fun onFetchAccountsFailure(throwable: Throwable) {
|
||||
fetching = false
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
Log.e(TAG, "Fetch failure", throwable)
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
if (throwable is IOException) {
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.hide()
|
||||
this.fetchAccounts(null)
|
||||
}
|
||||
} else {
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.hide()
|
||||
this.fetchAccounts(null)
|
||||
}
|
||||
binding.messageView.setup(throwable) {
|
||||
binding.messageView.hide()
|
||||
this.fetchAccounts(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,24 +12,26 @@
|
|||
*
|
||||
* 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
|
||||
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemFooterBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.removeDuplicates
|
||||
|
||||
/** Generic adapter with bottom loading indicator. */
|
||||
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
|
||||
var accountActionListener: AccountActionListener,
|
||||
protected val accountActionListener: AccountActionListener,
|
||||
protected val animateAvatar: Boolean,
|
||||
protected val animateEmojis: Boolean,
|
||||
protected val showBotOverlay: Boolean
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
|
||||
var accountList = mutableListOf<TimelineAccount>()
|
||||
|
||||
protected var accountList: MutableList<TimelineAccount> = mutableListOf()
|
||||
private var bottomLoading: Boolean = false
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
|
|
@ -59,11 +61,10 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
|
|||
}
|
||||
|
||||
private fun createFooterViewHolder(
|
||||
parent: ViewGroup,
|
||||
parent: ViewGroup
|
||||
): RecyclerView.ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_footer, parent, false)
|
||||
return LoadingFooterViewHolder(view)
|
||||
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* 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.accountlist.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemBlockedUserBinding
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
/** Displays a list of blocked accounts. */
|
||||
class BlocksAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) : AccountAdapter<BindingHolder<ItemBlockedUserBinding>>(
|
||||
accountActionListener = accountActionListener,
|
||||
animateAvatar = animateAvatar,
|
||||
animateEmojis = animateEmojis,
|
||||
showBotOverlay = showBotOverlay
|
||||
) {
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
|
||||
val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemBlockedUserBinding>, position: Int) {
|
||||
val account = accountList[position]
|
||||
val binding = viewHolder.binding
|
||||
val context = binding.root.context
|
||||
|
||||
val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis)
|
||||
binding.blockedUserDisplayName.text = emojifiedName
|
||||
val formattedUsername = context.getString(R.string.post_username_format, account.username)
|
||||
binding.blockedUserUsername.text = formattedUsername
|
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar)
|
||||
|
||||
binding.blockedUserBotBadge.visible(showBotOverlay && account.bot)
|
||||
|
||||
binding.blockedUserUnblock.setOnClickListener {
|
||||
accountActionListener.onBlock(false, account.id, position)
|
||||
}
|
||||
binding.root.setOnClickListener {
|
||||
accountActionListener.onViewAccount(account.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,10 +12,12 @@
|
|||
*
|
||||
* 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
|
||||
|
||||
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import com.keylesspalace.tusky.adapter.AccountViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemAccountBinding
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
|
||||
|
|
@ -26,17 +28,14 @@ class FollowAdapter(
|
|||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) : AccountAdapter<AccountViewHolder>(
|
||||
accountActionListener,
|
||||
animateAvatar,
|
||||
animateEmojis,
|
||||
showBotOverlay
|
||||
accountActionListener = accountActionListener,
|
||||
animateAvatar = animateAvatar,
|
||||
animateEmojis = animateEmojis,
|
||||
showBotOverlay = showBotOverlay
|
||||
) {
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
|
||||
val binding = ItemAccountBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return AccountViewHolder(binding)
|
||||
}
|
||||
|
||||
|
|
@ -12,29 +12,51 @@
|
|||
*
|
||||
* 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
|
||||
|
||||
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
|
||||
/** Displays a list of follow requests with accept/reject buttons. */
|
||||
class FollowRequestsAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) {
|
||||
) : AccountAdapter<FollowRequestViewHolder>(
|
||||
accountActionListener = accountActionListener,
|
||||
animateAvatar = animateAvatar,
|
||||
animateEmojis = animateEmojis,
|
||||
showBotOverlay = showBotOverlay
|
||||
) {
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
|
||||
val binding = ItemFollowRequestBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
showHeader = false
|
||||
)
|
||||
return FollowRequestViewHolder(binding, false)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
|
||||
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay)
|
||||
viewHolder.setupWithAccount(
|
||||
account = accountList[position],
|
||||
animateAvatar = animateAvatar,
|
||||
animateEmojis = animateEmojis,
|
||||
showBotOverlay = showBotOverlay
|
||||
)
|
||||
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,27 +13,28 @@
|
|||
* 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
|
||||
package com.keylesspalace.tusky.components.accountlist.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestsHeaderBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
|
||||
class FollowRequestsHeaderAdapter(private val instanceName: String, private val accountLocked: Boolean) : RecyclerView.Adapter<HeaderViewHolder>() {
|
||||
class FollowRequestsHeaderAdapter(
|
||||
private val instanceName: String,
|
||||
private val accountLocked: Boolean
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemFollowRequestsHeaderBinding>>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_follow_requests_header, parent, false) as TextView
|
||||
return HeaderViewHolder(view)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestsHeaderBinding> {
|
||||
val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: HeaderViewHolder, position: Int) {
|
||||
viewHolder.textView.text = viewHolder.textView.context.getString(R.string.follow_requests_info, instanceName)
|
||||
override fun onBindViewHolder(viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>, position: Int) {
|
||||
viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName)
|
||||
}
|
||||
|
||||
override fun getItemCount() = if (accountLocked) 0 else 1
|
||||
}
|
||||
|
||||
class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView)
|
||||
|
|
@ -1,4 +1,19 @@
|
|||
package com.keylesspalace.tusky.adapter
|
||||
/* 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.accountlist.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
|
|
@ -9,22 +24,21 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener
|
|||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
/**
|
||||
* Displays a list of muted accounts with mute/unmute account and mute/unmute notifications
|
||||
* buttons.
|
||||
* */
|
||||
/** Displays a list of muted accounts with mute/unmute account button and mute/unmute notifications switch */
|
||||
class MutesAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) : AccountAdapter<BindingHolder<ItemMutedUserBinding>>(
|
||||
accountActionListener,
|
||||
animateAvatar,
|
||||
animateEmojis,
|
||||
showBotOverlay
|
||||
accountActionListener = accountActionListener,
|
||||
animateAvatar = animateAvatar,
|
||||
animateEmojis = animateEmojis,
|
||||
showBotOverlay = showBotOverlay
|
||||
) {
|
||||
|
||||
private val mutingNotificationsMap = HashMap<String, Boolean>()
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
|
||||
|
|
@ -48,6 +62,8 @@ class MutesAdapter(
|
|||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
|
||||
|
||||
binding.mutedUserBotBadge.visible(showBotOverlay && account.bot)
|
||||
|
||||
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
|
||||
binding.mutedUserUnmute.contentDescription = unmuteString
|
||||
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
|
||||
|
|
@ -80,7 +80,7 @@ class AnnouncementAdapter(
|
|||
item.reactions.forEachIndexed { i, reaction ->
|
||||
(
|
||||
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
|
||||
?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
|
||||
?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply {
|
||||
isCheckable = true
|
||||
checkedIcon = null
|
||||
chips.addView(this, i)
|
||||
|
|
|
|||
|
|
@ -19,12 +19,17 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.PopupWindow
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
|
|
@ -39,11 +44,21 @@ import com.keylesspalace.tusky.util.Loading
|
|||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.view.EmojiPicker
|
||||
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 javax.inject.Inject
|
||||
|
||||
class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable {
|
||||
class AnnouncementsActivity :
|
||||
BottomSheetActivity(),
|
||||
AnnouncementActionListener,
|
||||
OnEmojiSelectedListener,
|
||||
MenuProvider,
|
||||
Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
|
@ -54,8 +69,8 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
|
|||
|
||||
private lateinit var adapter: AnnouncementAdapter
|
||||
|
||||
private val picker by lazy { EmojiPicker(this) }
|
||||
private val pickerDialog by lazy {
|
||||
private val picker by unsafeLazy { EmojiPicker(this) }
|
||||
private val pickerDialog by unsafeLazy {
|
||||
PopupWindow(this)
|
||||
.apply {
|
||||
contentView = picker
|
||||
|
|
@ -70,6 +85,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
addMenuProvider(this)
|
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
supportActionBar?.apply {
|
||||
|
|
@ -129,6 +145,27 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
|
|||
binding.progressBar.show()
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.activity_announcements, menu)
|
||||
menu.findItem(R.id.action_search)?.apply {
|
||||
icon = IconicsDrawable(this@AnnouncementsActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||
sizeDp = 20
|
||||
colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return when (menuItem.itemId) {
|
||||
R.id.action_refresh -> {
|
||||
binding.swipeRefreshLayout.isRefreshing = true
|
||||
refreshAnnouncements()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAnnouncements() {
|
||||
viewModel.load()
|
||||
binding.swipeRefreshLayout.isRefreshing = true
|
||||
|
|
|
|||
|
|
@ -107,8 +107,7 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
} else {
|
||||
listOf(
|
||||
*announcement.reactions.toTypedArray(),
|
||||
emojis.value!!.find { emoji -> emoji.shortcode == name }
|
||||
!!.run {
|
||||
emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run {
|
||||
Announcement.Reaction(
|
||||
name,
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.provider.MediaStore
|
||||
import android.text.Spanned
|
||||
import android.text.style.URLSpan
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
|
|
@ -51,10 +53,15 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.view.ContentInfoCompat
|
||||
import androidx.core.view.OnReceiveContentListener
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
|
@ -71,6 +78,7 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.adapter.EmojiAdapter
|
||||
import com.keylesspalace.tusky.adapter.LocaleAdapter
|
||||
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind
|
||||
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
|
||||
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
|
||||
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
|
||||
|
|
@ -88,18 +96,18 @@ import com.keylesspalace.tusky.entity.NewPoll
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.MentionSpan
|
||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.afterTextChanged
|
||||
import com.keylesspalace.tusky.util.getInitialLanguage
|
||||
import com.keylesspalace.tusky.util.getInitialLanguages
|
||||
import com.keylesspalace.tusky.util.getLocaleList
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.highlightSpans
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.modernLanguageCode
|
||||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.setDrawableTint
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
|
|
@ -137,9 +145,12 @@ class ComposeActivity :
|
|||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
||||
|
||||
/** The account that is being used to compose the status */
|
||||
private lateinit var activeAccount: AccountEntity
|
||||
|
||||
private var photoUploadUri: Uri? = null
|
||||
|
||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
|
||||
|
|
@ -203,10 +214,15 @@ class ComposeActivity :
|
|||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
|
||||
val accountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
|
||||
if (accountId != -1L) {
|
||||
accountManager.setActiveAccount(accountId)
|
||||
}
|
||||
// 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)
|
||||
if (theme == "black") {
|
||||
|
|
@ -215,20 +231,18 @@ class ComposeActivity :
|
|||
setContentView(binding.root)
|
||||
|
||||
setupActionBar()
|
||||
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
|
||||
val activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
setupAvatar(activeAccount)
|
||||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
CaptionDialog.newInstance(item.localId, item.description, item.uri)
|
||||
.show(supportFragmentManager, "caption_dialog")
|
||||
CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
|
||||
},
|
||||
onAddFocus = { item ->
|
||||
makeFocusDialog(item.focus, item.uri) { newFocus ->
|
||||
viewModel.updateFocus(item.localId, newFocus)
|
||||
}
|
||||
// TODO this is inconsistent to CaptionDialog (device rotation)?
|
||||
},
|
||||
onEditImage = this::editImageInQueue,
|
||||
onRemove = this::removeMediaFromQueue
|
||||
|
|
@ -240,7 +254,7 @@ class ComposeActivity :
|
|||
|
||||
/* If the composer is started up as a reply to another post, override the "starting" state
|
||||
* based on what the intent from the reply request passes. */
|
||||
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
|
||||
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java)
|
||||
viewModel.setup(composeOptions)
|
||||
|
||||
setupButtons()
|
||||
|
|
@ -266,7 +280,7 @@ class ComposeActivity :
|
|||
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount))
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
|
||||
setupComposeField(preferences, viewModel.startingText)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
|
|
@ -274,7 +288,7 @@ class ComposeActivity :
|
|||
|
||||
/* Finally, overwrite state with data from saved instance state. */
|
||||
savedInstanceState?.let {
|
||||
photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY)
|
||||
photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java)
|
||||
|
||||
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
|
||||
setStatusVisibility(this)
|
||||
|
|
@ -303,12 +317,12 @@ class ComposeActivity :
|
|||
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
|
|
@ -368,7 +382,7 @@ class ComposeActivity :
|
|||
if (startingContentWarning != null) {
|
||||
binding.composeContentWarningField.setText(startingContentWarning)
|
||||
}
|
||||
binding.composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||
binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||
}
|
||||
|
||||
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
|
||||
|
|
@ -391,8 +405,8 @@ class ComposeActivity :
|
|||
|
||||
val mentionColour = binding.composeEditField.linkTextColors.defaultColor
|
||||
highlightSpans(binding.composeEditField.text, mentionColour)
|
||||
binding.composeEditField.afterTextChanged { editable ->
|
||||
highlightSpans(editable, mentionColour)
|
||||
binding.composeEditField.doAfterTextChanged { editable ->
|
||||
highlightSpans(editable!!, mentionColour)
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
|
||||
|
|
@ -542,7 +556,7 @@ class ComposeActivity :
|
|||
)
|
||||
}
|
||||
|
||||
private fun setupLanguageSpinner(initialLanguage: String) {
|
||||
private fun setupLanguageSpinner(initialLanguages: List<String>) {
|
||||
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
|
||||
|
|
@ -553,7 +567,7 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
binding.composePostLanguageButton.apply {
|
||||
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage))
|
||||
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguages))
|
||||
setSelection(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -569,10 +583,10 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun setupAvatar(activeAccount: AccountEntity) {
|
||||
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
|
||||
val a = obtainStyledAttributes(null, actionBarSizeAttr)
|
||||
val avatarSize = a.getDimensionPixelSize(0, 1)
|
||||
a.recycle()
|
||||
val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize)
|
||||
val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a ->
|
||||
a.getDimensionPixelSize(0, 1)
|
||||
}
|
||||
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
loadAvatar(
|
||||
|
|
@ -697,7 +711,7 @@ class ComposeActivity :
|
|||
|
||||
var oneMediaWithoutDescription = false
|
||||
for (media in viewModel.media.value) {
|
||||
if (media.description == null || media.description.isEmpty()) {
|
||||
if (media.description.isNullOrEmpty()) {
|
||||
oneMediaWithoutDescription = true
|
||||
break
|
||||
}
|
||||
|
|
@ -807,25 +821,26 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun onMediaPick() {
|
||||
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
// Wait until bottom sheet is not collapsed and show next screen after
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.removeBottomSheetCallback(this)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this@ComposeActivity,
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
|
||||
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
|
||||
)
|
||||
} else {
|
||||
pickMediaFile.launch(true)
|
||||
addMediaBehavior.addBottomSheetCallback(
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
// Wait until bottom sheet is not collapsed and show next screen after
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.removeBottomSheetCallback(this)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this@ComposeActivity,
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
|
||||
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
|
||||
)
|
||||
} else {
|
||||
pickMediaFile.launch(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
}
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
}
|
||||
)
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
|
@ -881,20 +896,11 @@ class ComposeActivity :
|
|||
|
||||
@VisibleForTesting
|
||||
fun calculateTextLength(): Int {
|
||||
var offset = 0
|
||||
val urlSpans = binding.composeEditField.urls
|
||||
if (urlSpans != null) {
|
||||
for (span in urlSpans) {
|
||||
// it's expected that this will be negative
|
||||
// when the url length is less than the reserved character count
|
||||
offset += (span.url.length - charactersReservedPerUrl)
|
||||
}
|
||||
}
|
||||
var length = binding.composeEditField.length() - offset
|
||||
if (viewModel.showContentWarning.value) {
|
||||
length += binding.composeContentWarningField.length()
|
||||
}
|
||||
return length
|
||||
return statusLength(
|
||||
binding.composeEditField.text,
|
||||
binding.composeContentWarningField.text,
|
||||
charactersReservedPerUrl
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
|
@ -937,7 +943,10 @@ class ComposeActivity :
|
|||
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
|
||||
split.first?.let { content ->
|
||||
for (i in 0 until content.clip.itemCount) {
|
||||
pickMedia(content.clip.getItemAt(i).uri)
|
||||
pickMedia(
|
||||
content.clip.getItemAt(i).uri,
|
||||
contentInfo.clip.description.label as String?
|
||||
)
|
||||
}
|
||||
}
|
||||
return split.second
|
||||
|
|
@ -957,9 +966,8 @@ class ComposeActivity :
|
|||
binding.composeEditField.error = getString(R.string.error_empty)
|
||||
enableButtons(true, viewModel.editing)
|
||||
} else if (characterCount <= maximumTootCharacters) {
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.sendStatus(contentText, spoilerText)
|
||||
viewModel.sendStatus(contentText, spoilerText, activeAccount.id)
|
||||
deleteDraftAndFinish()
|
||||
}
|
||||
} else {
|
||||
|
|
@ -976,7 +984,8 @@ class ComposeActivity :
|
|||
pickMediaFile.launch(true)
|
||||
} else {
|
||||
Snackbar.make(
|
||||
binding.activityCompose, R.string.error_media_upload_permission,
|
||||
binding.activityCompose,
|
||||
R.string.error_media_upload_permission,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).apply {
|
||||
setAction(R.string.action_retry) { onMediaPick() }
|
||||
|
|
@ -1010,9 +1019,13 @@ class ComposeActivity :
|
|||
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
|
||||
button.isEnabled = clickable
|
||||
setDrawableTint(
|
||||
this, button.drawable,
|
||||
if (colorActive) android.R.attr.textColorTertiary
|
||||
else R.attr.textColorDisabled
|
||||
this,
|
||||
button.drawable,
|
||||
if (colorActive) {
|
||||
android.R.attr.textColorTertiary
|
||||
} else {
|
||||
R.attr.textColorDisabled
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1020,8 +1033,11 @@ class ComposeActivity :
|
|||
binding.addPollTextActionTextView.isEnabled = enable
|
||||
val textColor = MaterialColors.getColor(
|
||||
binding.addPollTextActionTextView,
|
||||
if (enable) android.R.attr.textColorTertiary
|
||||
else R.attr.textColorDisabled
|
||||
if (enable) {
|
||||
android.R.attr.textColorTertiary
|
||||
} else {
|
||||
R.attr.textColorDisabled
|
||||
}
|
||||
)
|
||||
binding.addPollTextActionTextView.setTextColor(textColor)
|
||||
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
|
|
@ -1051,9 +1067,9 @@ class ComposeActivity :
|
|||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
||||
private fun pickMedia(uri: Uri) {
|
||||
private fun pickMedia(uri: Uri, description: String? = null) {
|
||||
lifecycleScope.launch {
|
||||
viewModel.pickMedia(uri).onFailure { throwable ->
|
||||
viewModel.pickMedia(uri, description).onFailure { throwable ->
|
||||
val errorString = when (throwable) {
|
||||
is FileSizeException -> {
|
||||
val decimalFormat = DecimalFormat("0.##")
|
||||
|
|
@ -1114,16 +1130,19 @@ class ComposeActivity :
|
|||
private fun handleCloseButton() {
|
||||
val contentText = binding.composeEditField.text.toString()
|
||||
val contentWarning = binding.composeContentWarningField.text.toString()
|
||||
if (viewModel.didChange(contentText, contentWarning)) {
|
||||
when (viewModel.composeKind) {
|
||||
ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning)
|
||||
ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning)
|
||||
ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog()
|
||||
ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog()
|
||||
}.show()
|
||||
} else {
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
when (viewModel.handleCloseButton(contentText, contentWarning)) {
|
||||
ConfirmationKind.NONE -> {
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
ConfirmationKind.SAVE_OR_DISCARD ->
|
||||
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||
ConfirmationKind.UPDATE_OR_DISCARD ->
|
||||
getUpdateDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES ->
|
||||
getContinueEditingOrDiscardDialog().show()
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT ->
|
||||
getDeleteEmptyDraftOrContinueEditing().show()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1188,6 +1207,23 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User is editing an existing draft and making it empty.
|
||||
* The user can either delete the empty draft or go back to editing.
|
||||
*/
|
||||
private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder {
|
||||
return AlertDialog.Builder(this)
|
||||
.setMessage(R.string.compose_delete_draft)
|
||||
.setPositiveButton(R.string.action_delete) { _, _ ->
|
||||
viewModel.deleteDraft()
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
|
||||
// Do nothing, dialog will dismiss, user can continue editing
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
|
|
@ -1197,8 +1233,11 @@ class ComposeActivity :
|
|||
lifecycleScope.launch {
|
||||
val dialog = if (viewModel.shouldShowSaveDraftDialog()) {
|
||||
ProgressDialog.show(
|
||||
this@ComposeActivity, null,
|
||||
getString(R.string.saving_draft), true, false
|
||||
this@ComposeActivity,
|
||||
null,
|
||||
getString(R.string.saving_draft),
|
||||
true,
|
||||
false
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
|
@ -1259,11 +1298,7 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
override fun onUpdateDescription(localId: Int, description: String) {
|
||||
lifecycleScope.launch {
|
||||
if (!viewModel.updateDescription(localId, description)) {
|
||||
Toast.makeText(this@ComposeActivity, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
viewModel.updateDescription(localId, description)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1350,5 +1385,53 @@ class ComposeActivity :
|
|||
fun canHandleMimeType(mimeType: String?): Boolean {
|
||||
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the effective status length.
|
||||
*
|
||||
* Some text is counted differently:
|
||||
*
|
||||
* In the status body:
|
||||
*
|
||||
* - URLs always count for [urlLength] characters irrespective of their actual length
|
||||
* (https://docs.joinmastodon.org/user/posting/#links)
|
||||
* - Mentions ("@user@some.instance") only count the "@user" part
|
||||
* (https://docs.joinmastodon.org/user/posting/#mentions)
|
||||
* - Hashtags are always treated as their actual length, including the "#"
|
||||
* (https://docs.joinmastodon.org/user/posting/#hashtags)
|
||||
*
|
||||
* Content warning text is always treated as its full length, URLs and other entities
|
||||
* are not treated differently.
|
||||
*
|
||||
* @param body status body text
|
||||
* @param contentWarning optional content warning text
|
||||
* @param urlLength the number of characters attributed to URLs
|
||||
* @return the effective status length
|
||||
*/
|
||||
@JvmStatic
|
||||
fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
|
||||
var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
|
||||
.fold(0) { acc, span ->
|
||||
// Accumulate a count of characters to be *ignored* in the final length
|
||||
acc + when (span) {
|
||||
is MentionSpan -> {
|
||||
// Ignore everything from the second "@" (if present)
|
||||
span.url.length - (
|
||||
span.url.indexOf("@", 1).takeIf { it >= 0 }
|
||||
?: span.url.length
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// Expected to be negative if the URL length < maxUrlLength
|
||||
span.url.length - urlLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content warning text is treated as is, URLs or mentions there are not special
|
||||
contentWarning?.let { length += it.length }
|
||||
|
||||
return length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.core.net.toUri
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
|
|
@ -48,7 +49,6 @@ import kotlinx.coroutines.flow.asFlow
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
|
@ -95,7 +95,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
lateinit var composeKind: ComposeActivity.ComposeKind
|
||||
lateinit var composeKind: ComposeKind
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
|
@ -130,7 +130,7 @@ class ComposeViewModel @Inject constructor(
|
|||
): QueuedMedia {
|
||||
var stashMediaItem: QueuedMedia? = null
|
||||
|
||||
media.updateAndGet { mediaValue ->
|
||||
media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
|
|
@ -144,11 +144,11 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
if (replaceItem != null) {
|
||||
mediaUploader.cancelUploadScope(replaceItem.localId)
|
||||
mediaValue.map {
|
||||
mediaList.map {
|
||||
if (it.localId == replaceItem.localId) mediaItem else it
|
||||
}
|
||||
} else { // Append
|
||||
mediaValue + mediaItem
|
||||
mediaList + mediaItem
|
||||
}
|
||||
}
|
||||
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
|
||||
|
|
@ -169,13 +169,13 @@ class ComposeViewModel @Inject constructor(
|
|||
state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED }
|
||||
)
|
||||
is UploadEvent.ErrorEvent -> {
|
||||
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
|
||||
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||
uploadError.emit(event.error)
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
media.update { mediaValue ->
|
||||
mediaValue.map { mediaItem ->
|
||||
media.update { mediaList ->
|
||||
mediaList.map { mediaItem ->
|
||||
if (mediaItem.localId == newMediaItem.localId) {
|
||||
newMediaItem
|
||||
} else {
|
||||
|
|
@ -189,7 +189,7 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
||||
media.update { mediaValue ->
|
||||
media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
|
|
@ -201,20 +201,41 @@ class ComposeViewModel @Inject constructor(
|
|||
focus = focus,
|
||||
state = QueuedMedia.State.PUBLISHED
|
||||
)
|
||||
mediaValue + mediaItem
|
||||
mediaList + mediaItem
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaUploader.cancelUploadScope(item.localId)
|
||||
media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
|
||||
media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
|
||||
}
|
||||
|
||||
fun toggleMarkSensitive() {
|
||||
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
|
||||
}
|
||||
|
||||
fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
|
||||
return if (didChange(contentText, contentWarning)) {
|
||||
when (composeKind) {
|
||||
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
|
||||
ConfirmationKind.NONE
|
||||
} else {
|
||||
ConfirmationKind.SAVE_OR_DISCARD
|
||||
}
|
||||
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
|
||||
} else {
|
||||
ConfirmationKind.UPDATE_OR_DISCARD
|
||||
}
|
||||
ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
|
||||
ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
|
||||
}
|
||||
} else {
|
||||
ConfirmationKind.NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
val textChanged = content.orEmpty() != startingText.orEmpty()
|
||||
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
|
|
@ -224,6 +245,10 @@ class ComposeViewModel @Inject constructor(
|
|||
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
|
||||
}
|
||||
|
||||
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
|
||||
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
|
||||
}
|
||||
|
||||
fun contentWarningChanged(value: Boolean) {
|
||||
showContentWarning.value = value
|
||||
contentWarningStateChanged = true
|
||||
|
|
@ -274,7 +299,7 @@ class ComposeViewModel @Inject constructor(
|
|||
failedToSendAlert = false,
|
||||
scheduledAt = scheduledAt.value,
|
||||
language = postLanguage,
|
||||
statusId = originalStatusId,
|
||||
statusId = originalStatusId
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -284,9 +309,9 @@ class ComposeViewModel @Inject constructor(
|
|||
*/
|
||||
suspend fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String
|
||||
spoilerText: String,
|
||||
accountId: Long
|
||||
) {
|
||||
|
||||
if (!scheduledTootId.isNullOrEmpty()) {
|
||||
api.deleteScheduledStatus(scheduledTootId!!)
|
||||
}
|
||||
|
|
@ -312,7 +337,7 @@ class ComposeViewModel @Inject constructor(
|
|||
poll = poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
accountId = accountId,
|
||||
draftId = draftId,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0,
|
||||
|
|
@ -323,10 +348,9 @@ class ComposeViewModel @Inject constructor(
|
|||
serviceClient.sendToot(tootToSend)
|
||||
}
|
||||
|
||||
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server
|
||||
private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean {
|
||||
val newMediaList = media.updateAndGet { mediaValue ->
|
||||
mediaValue.map { mediaItem ->
|
||||
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
|
||||
media.update { mediaList ->
|
||||
mediaList.map { mediaItem ->
|
||||
if (mediaItem.localId == localId) {
|
||||
mutator(mediaItem)
|
||||
} else {
|
||||
|
|
@ -334,30 +358,16 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val updatedItem = newMediaList.find { it.localId == localId }
|
||||
if (updatedItem?.id != null) {
|
||||
val focus = updatedItem.focus
|
||||
val focusString = if (focus != null) "${focus.x},${focus.y}" else null
|
||||
return api.updateMedia(updatedItem.id, updatedItem.description, focusString)
|
||||
.fold({
|
||||
true
|
||||
}, { throwable ->
|
||||
Log.w(TAG, "failed to update media", throwable)
|
||||
false
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun updateDescription(localId: Int, description: String): Boolean {
|
||||
return updateMediaItem(localId) { mediaItem ->
|
||||
fun updateDescription(localId: Int, description: String) {
|
||||
updateMediaItem(localId) { mediaItem ->
|
||||
mediaItem.copy(description = description)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
|
||||
return updateMediaItem(localId) { mediaItem ->
|
||||
fun updateFocus(localId: Int, focus: Attachment.Focus) {
|
||||
updateMediaItem(localId) { mediaItem ->
|
||||
mediaItem.copy(focus = focus)
|
||||
}
|
||||
}
|
||||
|
|
@ -402,12 +412,11 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
|
||||
|
||||
if (setupComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW
|
||||
composeKind = composeOptions?.kind ?: ComposeKind.NEW
|
||||
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
|
||||
|
|
@ -437,14 +446,16 @@ class ComposeViewModel @Inject constructor(
|
|||
pickMedia(attachment.uri, attachment.description, attachment.focus)
|
||||
}
|
||||
}
|
||||
} else composeOptions?.mediaAttachments?.forEach { a ->
|
||||
// when coming from redraft or ScheduledTootActivity
|
||||
val mediaType = when (a.type) {
|
||||
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
|
||||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
|
||||
} else {
|
||||
composeOptions?.mediaAttachments?.forEach { a ->
|
||||
// when coming from redraft or ScheduledTootActivity
|
||||
val mediaType = when (a.type) {
|
||||
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
|
||||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
|
||||
}
|
||||
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
|
||||
}
|
||||
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
|
||||
}
|
||||
|
||||
draftId = composeOptions?.draftId ?: 0
|
||||
|
|
@ -501,6 +512,14 @@ class ComposeViewModel @Inject constructor(
|
|||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
||||
enum class ConfirmationKind {
|
||||
NONE, // just close
|
||||
SAVE_OR_DISCARD,
|
||||
UPDATE_OR_DISCARD,
|
||||
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
|
||||
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ fun downsizeImage(
|
|||
contentResolver: ContentResolver,
|
||||
tempFile: File
|
||||
): Boolean {
|
||||
|
||||
val decodeBoundsInputStream = try {
|
||||
contentResolver.openInputStream(uri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
|
|
|
|||
|
|
@ -48,11 +48,12 @@ class MediaPreviewAdapter(
|
|||
val addFocusId = 2
|
||||
val editImageId = 3
|
||||
val removeId = 4
|
||||
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
|
||||
// Already-published items can't have their metadata edited
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
|
||||
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
|
||||
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
|
||||
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
|
||||
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
|
||||
// Already-published items can't be edited
|
||||
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
||||
}
|
||||
}
|
||||
|
|
@ -89,10 +90,11 @@ class MediaPreviewAdapter(
|
|||
val imageView = holder.progressImageView
|
||||
val focus = item.focus
|
||||
|
||||
if (focus != null)
|
||||
if (focus != null) {
|
||||
imageView.setFocalPoint(focus)
|
||||
else
|
||||
} else {
|
||||
imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added.
|
||||
}
|
||||
|
||||
var glide = Glide.with(holder.itemView.context)
|
||||
.load(item.uri)
|
||||
|
|
@ -100,8 +102,9 @@ class MediaPreviewAdapter(
|
|||
.dontAnimate()
|
||||
.centerInside()
|
||||
|
||||
if (focus != null)
|
||||
if (focus != null) {
|
||||
glide = glide.addListener(imageView)
|
||||
}
|
||||
|
||||
glide.into(imageView)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,6 @@ class MediaUploader @Inject constructor(
|
|||
try {
|
||||
when (inUri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> {
|
||||
|
||||
mimeType = contentResolver.getType(uri)
|
||||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
|
@ -278,7 +277,8 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(
|
||||
stream!!, media.mediaSize,
|
||||
stream!!,
|
||||
media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()!!
|
||||
) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ fun showAddPollDialog(
|
|||
maxDuration: Int,
|
||||
onUpdatePoll: (NewPoll) -> Unit
|
||||
) {
|
||||
|
||||
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
|
||||
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
|
|
@ -63,7 +62,7 @@ fun showAddPollDialog(
|
|||
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
|
||||
val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
|
||||
binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply {
|
||||
setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item)
|
||||
setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item)
|
||||
}
|
||||
durations = durations.filter { it in minDuration..maxDuration }
|
||||
|
||||
|
|
@ -76,8 +75,10 @@ fun showAddPollDialog(
|
|||
}
|
||||
}
|
||||
|
||||
val DAY_SECONDS = 60 * 60 * 24
|
||||
val desiredDuration = poll?.expiresIn ?: DAY_SECONDS
|
||||
val pollDurationId = durations.indexOfLast {
|
||||
it <= (poll?.expiresIn ?: 0)
|
||||
it <= desiredDuration
|
||||
}
|
||||
|
||||
binding.pollDurationSpinner.setSelection(pollDurationId)
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ import android.text.InputFilter
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemAddPollOptionBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class AddPollOptionsAdapter(
|
||||
|
|
@ -46,7 +46,7 @@ class AddPollOptionsAdapter(
|
|||
val holder = BindingHolder(binding)
|
||||
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
|
||||
|
||||
binding.optionEditText.onTextChanged { s, _, _, _ ->
|
||||
binding.optionEditText.doOnTextChanged { s, _, _, _ ->
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) {
|
||||
options[pos] = s.toString()
|
||||
|
|
|
|||
|
|
@ -21,79 +21,60 @@ import android.graphics.drawable.Drawable
|
|||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.InputFilter
|
||||
import android.text.InputType
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.github.chrisbanes.photoview.PhotoView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
||||
|
||||
class CaptionDialog : DialogFragment() {
|
||||
|
||||
private lateinit var listener: Listener
|
||||
private lateinit var input: EditText
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
val dialogLayout = LinearLayout(context)
|
||||
val padding = Utils.dpToPx(context, 8)
|
||||
dialogLayout.setPadding(padding, padding, padding, padding)
|
||||
|
||||
dialogLayout.orientation = LinearLayout.VERTICAL
|
||||
val imageView = PhotoView(context).apply {
|
||||
maximumScale = 6f
|
||||
}
|
||||
val binding = DialogImageDescriptionBinding.inflate(layoutInflater)
|
||||
|
||||
val margin = Utils.dpToPx(context, 4)
|
||||
dialogLayout.addView(imageView)
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
|
||||
imageView.layoutParams.height = 0
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
|
||||
input = binding.imageDescriptionText
|
||||
val imageView = binding.imageDescriptionView
|
||||
imageView.maximumScale = 6f
|
||||
|
||||
input = EditText(context)
|
||||
input.hint = resources.getQuantityString(
|
||||
R.plurals.hint_describe_for_visually_impaired,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT
|
||||
)
|
||||
dialogLayout.addView(input)
|
||||
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
|
||||
input.setLines(2)
|
||||
input.inputType = (
|
||||
InputType.TYPE_CLASS_TEXT
|
||||
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
||||
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||
)
|
||||
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(dialogLayout)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
listener.onUpdateDescription(localId, input.text.toString())
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
||||
isCancelable = false
|
||||
isCancelable = true
|
||||
val window = dialog.window
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
val previewUri = arguments?.getParcelable<Uri>(PREVIEW_URI_ARG) ?: error("Preview Uri is null")
|
||||
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
|
||||
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
|
|
@ -105,7 +86,7 @@ class CaptionDialog : DialogFragment() {
|
|||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
transition: Transition<in Drawable>?,
|
||||
transition: Transition<in Drawable>?
|
||||
) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
|
|
@ -122,7 +103,7 @@ class CaptionDialog : DialogFragment() {
|
|||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
|
||||
input.setText(it)
|
||||
|
|
@ -143,12 +124,12 @@ class CaptionDialog : DialogFragment() {
|
|||
fun newInstance(
|
||||
localId: Int,
|
||||
existingDescription: String?,
|
||||
previewUri: Uri,
|
||||
previewUri: Uri
|
||||
) = CaptionDialog().apply {
|
||||
arguments = bundleOf(
|
||||
LOCAL_ID_ARG to localId,
|
||||
EXISTING_DESCRIPTION_ARG to existingDescription,
|
||||
PREVIEW_URI_ARG to previewUri,
|
||||
PREVIEW_URI_ARG to previewUri
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,14 +15,13 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
|
|
@ -31,7 +30,6 @@ import com.bumptech.glide.load.engine.GlideException
|
|||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.DialogFocusBinding
|
||||
import com.keylesspalace.tusky.entity.Attachment.Focus
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -39,8 +37,8 @@ import kotlinx.coroutines.launch
|
|||
fun <T> T.makeFocusDialog(
|
||||
existingFocus: Focus?,
|
||||
previewUri: Uri,
|
||||
onUpdateFocus: suspend (Focus) -> Boolean
|
||||
) where T : Activity, T : LifecycleOwner {
|
||||
onUpdateFocus: suspend (Focus) -> Unit
|
||||
) where T : AppCompatActivity, T : LifecycleOwner {
|
||||
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
|
||||
|
||||
val dialogBinding = DialogFocusBinding.inflate(layoutInflater)
|
||||
|
|
@ -79,9 +77,7 @@ fun <T> T.makeFocusDialog(
|
|||
|
||||
val okListener = { dialog: DialogInterface, _: Int ->
|
||||
lifecycleScope.launch {
|
||||
if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) {
|
||||
showFailedFocusMessage()
|
||||
}
|
||||
onUpdateFocus(dialogBinding.focusIndicator.getFocus())
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
|
@ -99,7 +95,3 @@ fun <T> T.makeFocusDialog(
|
|||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun Activity.showFailedFocusMessage() {
|
||||
Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,14 +27,16 @@ class FocusIndicatorView
|
|||
|
||||
fun setImageSize(width: Int, height: Int) {
|
||||
this.imageSize = Point(width, height)
|
||||
if (focus != null)
|
||||
if (focus != null) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun setFocus(focus: Attachment.Focus) {
|
||||
this.focus = focus
|
||||
if (imageSize != null)
|
||||
if (imageSize != null) {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// Assumes setFocus called first
|
||||
|
|
@ -46,8 +48,9 @@ class FocusIndicatorView
|
|||
// so base it on the view width/height whenever the first access occurs.
|
||||
private fun getCircleRadius(): Float {
|
||||
val circleRadius = this.circleRadius
|
||||
if (circleRadius != null)
|
||||
if (circleRadius != null) {
|
||||
return circleRadius
|
||||
}
|
||||
val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f
|
||||
this.circleRadius = newCircleRadius
|
||||
return newCircleRadius
|
||||
|
|
@ -67,12 +70,11 @@ class FocusIndicatorView
|
|||
|
||||
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL)
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
return false
|
||||
}
|
||||
|
||||
val imageSize = this.imageSize
|
||||
if (imageSize == null)
|
||||
return false
|
||||
val imageSize = this.imageSize ?: return false
|
||||
|
||||
// Convert touch xy to point inside image
|
||||
focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height))
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ class TootButton
|
|||
|
||||
fun setStatusVisibility(visibility: Status.Visibility) {
|
||||
if (!smallStyle) {
|
||||
|
||||
icon = when (visibility) {
|
||||
Status.Visibility.PUBLIC -> {
|
||||
setText(R.string.action_send_public)
|
||||
|
|
|
|||
|
|
@ -64,9 +64,10 @@ data class ConversationAccountEntity(
|
|||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
note = "",
|
||||
url = "",
|
||||
avatar = avatar,
|
||||
emojis = emojis,
|
||||
emojis = emojis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +89,7 @@ data class ConversationStatusEntity(
|
|||
val bookmarked: Boolean,
|
||||
val sensitive: Boolean,
|
||||
val spoilerText: String,
|
||||
val attachments: ArrayList<Attachment>,
|
||||
val attachments: List<Attachment>,
|
||||
val mentions: List<Status.Mention>,
|
||||
val tags: List<HashTag>?,
|
||||
val showingHiddenContent: Boolean,
|
||||
|
|
@ -96,7 +97,7 @@ data class ConversationStatusEntity(
|
|||
val collapsed: Boolean,
|
||||
val muted: Boolean,
|
||||
val poll: Poll?,
|
||||
val language: String?,
|
||||
val language: String?
|
||||
) {
|
||||
|
||||
fun toViewData(): StatusViewData.Concrete {
|
||||
|
|
@ -130,6 +131,7 @@ data class ConversationStatusEntity(
|
|||
poll = poll,
|
||||
card = null,
|
||||
language = language,
|
||||
filtered = null
|
||||
),
|
||||
isExpanded = expanded,
|
||||
isShowingContent = showingHiddenContent,
|
||||
|
|
@ -145,7 +147,7 @@ fun TimelineAccount.toEntity() =
|
|||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis ?: emptyList()
|
||||
emojis = emojis.orEmpty()
|
||||
)
|
||||
|
||||
fun Status.toEntity(
|
||||
|
|
@ -177,7 +179,7 @@ fun Status.toEntity(
|
|||
collapsed = contentCollapsed,
|
||||
muted = muted ?: false,
|
||||
poll = poll,
|
||||
language = language,
|
||||
language = language
|
||||
)
|
||||
|
||||
fun Conversation.toEntity(
|
||||
|
|
|
|||
|
|
@ -87,6 +87,6 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
|
|||
collapsed = collapsed,
|
||||
muted = muted,
|
||||
poll = poll,
|
||||
language = status.language,
|
||||
language = status.language
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
|||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.util.List;
|
||||
|
|
@ -110,9 +109,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
|
||||
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
||||
status.getMentions(), status.getTags(), status.getEmojis(),
|
||||
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
||||
setSpoilerAndContent(statusViewData, statusDisplayOptions, listener);
|
||||
|
||||
setConversationName(conversation.getAccounts());
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,18 @@ package com.keylesspalace.tusky.components.conversation
|
|||
|
||||
import android.os.Bundle
|
||||
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.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
|
|
@ -31,7 +36,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.autoDispose
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
|
|
@ -52,16 +57,23 @@ import com.keylesspalace.tusky.util.hide
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
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.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
||||
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
||||
class ConversationsFragment :
|
||||
SFragment(),
|
||||
StatusActionListener,
|
||||
Injectable,
|
||||
ReselectableFragment,
|
||||
MenuProvider {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
|
@ -82,6 +94,8 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
|
|
@ -94,7 +108,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
||||
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia,
|
||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
)
|
||||
|
||||
adapter = ConversationAdapter(statusDisplayOptions, this)
|
||||
|
|
@ -121,12 +138,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||
}
|
||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
|
|
@ -171,22 +183,48 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launchWhenResumed {
|
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||
while (!useAbsoluteTime) {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||
delay(1.toDuration(DurationUnit.MINUTES))
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||
while (!useAbsoluteTime) {
|
||||
adapter.notifyItemRangeChanged(
|
||||
0,
|
||||
adapter.itemCount,
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
)
|
||||
delay(1.toDuration(DurationUnit.MINUTES))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe { event ->
|
||||
lifecycleScope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
if (event is PreferenceChangedEvent) {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.fragment_conversations, 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
|
||||
refreshContent()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
|
|
@ -200,10 +238,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||
}
|
||||
|
||||
private fun refreshContent() {
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
private fun initSwipeToRefresh() {
|
||||
binding.swipeRefreshLayout.setOnRefreshListener {
|
||||
adapter.refresh()
|
||||
}
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
}
|
||||
|
||||
|
|
@ -311,6 +351,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
}
|
||||
|
||||
override fun clearWarningAction(position: Int) {
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import retrofit2.HttpException
|
|||
class ConversationsRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val db: AppDatabase,
|
||||
accountManager: AccountManager,
|
||||
accountManager: AccountManager
|
||||
) : RemoteMediator<Int, ConversationEntity>() {
|
||||
|
||||
private var nextKey: String? = null
|
||||
|
|
@ -28,7 +28,6 @@ class ConversationsRemoteMediator(
|
|||
loadType: LoadType,
|
||||
state: PagingState<Int, ConversationEntity>
|
||||
): MediatorResult {
|
||||
|
||||
if (loadType == LoadType.PREPEND) {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
|
|
@ -47,7 +46,6 @@ class ConversationsRemoteMediator(
|
|||
}
|
||||
|
||||
db.withTransaction {
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.conversationDao().deleteForAccount(activeAccount.id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.paging.Pager
|
|||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
|
|
@ -30,7 +31,6 @@ import com.keylesspalace.tusky.usecase.TimelineCases
|
|||
import com.keylesspalace.tusky.util.EmptyPagingSource
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import javax.inject.Inject
|
||||
|
||||
class ConversationsViewModel @Inject constructor(
|
||||
|
|
@ -61,51 +61,47 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
fun favourite(favourite: Boolean, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
|
||||
|
||||
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
favourited = favourite
|
||||
)
|
||||
|
||||
saveConversationToDb(newConversation)
|
||||
} catch (e: Exception) {
|
||||
}, { e ->
|
||||
Log.w(TAG, "failed to favourite status", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
|
||||
|
||||
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
bookmarked = bookmark
|
||||
)
|
||||
|
||||
saveConversationToDb(newConversation)
|
||||
} catch (e: Exception) {
|
||||
}, { e ->
|
||||
Log.w(TAG, "failed to bookmark status", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
poll = poll
|
||||
)
|
||||
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
|
||||
.fold({ poll ->
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
poll = poll
|
||||
)
|
||||
|
||||
saveConversationToDb(newConversation)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed to vote in poll", e)
|
||||
}
|
||||
saveConversationToDb(newConversation)
|
||||
}, { e ->
|
||||
Log.w(TAG, "failed to vote in poll", e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +156,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
timelineCases.muteConversation(
|
||||
conversation.lastStatus.id,
|
||||
!(conversation.lastStatus.status.muted ?: false)
|
||||
).await()
|
||||
)
|
||||
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import javax.inject.Inject
|
|||
|
||||
class DraftHelper @Inject constructor(
|
||||
val context: Context,
|
||||
val okHttpClient: OkHttpClient,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
db: AppDatabase
|
||||
) {
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ class DraftHelper @Inject constructor(
|
|||
failedToSendAlert: Boolean,
|
||||
scheduledAt: String?,
|
||||
language: String?,
|
||||
statusId: String?,
|
||||
statusId: String?
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val externalFilesDir = context.getExternalFilesDir("Tusky")
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ class DraftHelper @Inject constructor(
|
|||
failedToSendNew = failedToSendAlert,
|
||||
scheduledAt = scheduledAt,
|
||||
language = language,
|
||||
statusId = statusId,
|
||||
statusId = statusId
|
||||
)
|
||||
|
||||
draftDao.insertOrReplace(draft)
|
||||
|
|
@ -140,7 +140,7 @@ class DraftHelper @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
|
||||
private suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
|
||||
deleteAttachments(draft)
|
||||
draftDao.delete(draft.id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,18 +51,20 @@ class DraftMediaAdapter(
|
|||
holder.imageView.clearFocus()
|
||||
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
||||
} else {
|
||||
if (attachment.focus != null)
|
||||
if (attachment.focus != null) {
|
||||
holder.imageView.setFocalPoint(attachment.focus)
|
||||
else
|
||||
} else {
|
||||
holder.imageView.clearFocus()
|
||||
}
|
||||
var glide = Glide.with(holder.itemView.context)
|
||||
.load(attachment.uri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.dontAnimate()
|
||||
.centerInside()
|
||||
|
||||
if (attachment.focus != null)
|
||||
if (attachment.focus != null) {
|
||||
glide = glide.addListener(holder.imageView)
|
||||
}
|
||||
|
||||
glide.into(holder.imageView)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ class DraftsAdapter(
|
|||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemDraftBinding> {
|
||||
|
||||
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
|
||||
val viewHolder = BindingHolder(binding)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class DraftsViewModel @Inject constructor(
|
|||
val database: AppDatabase,
|
||||
val accountManager: AccountManager,
|
||||
val api: MastodonApi,
|
||||
val draftHelper: DraftHelper
|
||||
private val draftHelper: DraftHelper
|
||||
) : ViewModel() {
|
||||
|
||||
val drafts = Pager(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.view.size
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
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.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.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
class EditFilterActivity : BaseActivity() {
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val binding by viewBinding(ActivityEditFilterBinding::inflate)
|
||||
private val viewModel: EditFilterViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private lateinit var filter: Filter
|
||||
private var originalFilter: Filter? = null
|
||||
private lateinit var contextSwitches: Map<SwitchMaterial, Filter.Kind>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java)
|
||||
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
|
||||
binding.apply {
|
||||
contextSwitches = mapOf(
|
||||
filterContextHome to Filter.Kind.HOME,
|
||||
filterContextNotifications to Filter.Kind.NOTIFICATIONS,
|
||||
filterContextPublic to Filter.Kind.PUBLIC,
|
||||
filterContextThread to Filter.Kind.THREAD,
|
||||
filterContextAccount to Filter.Kind.ACCOUNT
|
||||
)
|
||||
}
|
||||
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
supportActionBar?.run {
|
||||
// Back button
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
setTitle(
|
||||
if (originalFilter == null) {
|
||||
R.string.filter_addition_title
|
||||
} else {
|
||||
R.string.filter_edit_title
|
||||
}
|
||||
)
|
||||
|
||||
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
|
||||
binding.filterSaveButton.setOnClickListener { saveChanges() }
|
||||
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
|
||||
binding.filterDeleteButton.visible(originalFilter != null)
|
||||
|
||||
for (switch in contextSwitches.keys) {
|
||||
switch.setOnCheckedChangeListener { _, isChecked ->
|
||||
val context = contextSwitches[switch]!!
|
||||
if (isChecked) {
|
||||
viewModel.addContext(context)
|
||||
} else {
|
||||
viewModel.removeContext(context)
|
||||
}
|
||||
validateSaveButton()
|
||||
}
|
||||
}
|
||||
binding.filterTitle.doAfterTextChanged { editable ->
|
||||
viewModel.setTitle(editable.toString())
|
||||
validateSaveButton()
|
||||
}
|
||||
binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setAction(
|
||||
if (checked) {
|
||||
Filter.Action.WARN
|
||||
} else {
|
||||
Filter.Action.HIDE
|
||||
}
|
||||
)
|
||||
}
|
||||
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
viewModel.setDuration(
|
||||
if (originalFilter?.expiresAt == null) {
|
||||
position
|
||||
} else {
|
||||
position - 1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
viewModel.setDuration(0)
|
||||
}
|
||||
}
|
||||
validateSaveButton()
|
||||
|
||||
if (originalFilter == null) {
|
||||
binding.filterActionWarn.isChecked = true
|
||||
} else {
|
||||
loadFilter()
|
||||
}
|
||||
observeModel()
|
||||
}
|
||||
|
||||
private fun observeModel() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.title.collect { title ->
|
||||
if (title != binding.filterTitle.text.toString()) {
|
||||
// We also get this callback when typing in the field,
|
||||
// which messes with the cursor focus
|
||||
binding.filterTitle.setText(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.keywords.collect { keywords ->
|
||||
updateKeywords(keywords)
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.contexts.collect { contexts ->
|
||||
for (entry in contextSwitches) {
|
||||
entry.key.isChecked = contexts.contains(entry.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.action.collect { action ->
|
||||
when (action) {
|
||||
Filter.Action.HIDE -> binding.filterActionHide.isChecked = true
|
||||
else -> binding.filterActionWarn.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the UI from the filter's members
|
||||
private fun loadFilter() {
|
||||
viewModel.load(filter)
|
||||
if (filter.expiresAt != null) {
|
||||
val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
|
||||
binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateKeywords(newKeywords: List<FilterKeyword>) {
|
||||
newKeywords.forEachIndexed { index, filterKeyword ->
|
||||
val chip = binding.keywordChips.getChildAt(index).takeUnless {
|
||||
it.id == R.id.actionChip
|
||||
} as Chip? ?: Chip(this).apply {
|
||||
setCloseIconResource(R.drawable.ic_cancel_24dp)
|
||||
isCheckable = false
|
||||
binding.keywordChips.addView(this, binding.keywordChips.size - 1)
|
||||
}
|
||||
|
||||
chip.text = if (filterKeyword.wholeWord) {
|
||||
binding.root.context.getString(
|
||||
R.string.filter_keyword_display_format,
|
||||
filterKeyword.keyword
|
||||
)
|
||||
} else {
|
||||
filterKeyword.keyword
|
||||
}
|
||||
chip.isCloseIconVisible = true
|
||||
chip.setOnClickListener {
|
||||
showEditKeywordDialog(newKeywords[index])
|
||||
}
|
||||
chip.setOnCloseIconClickListener {
|
||||
viewModel.deleteKeyword(newKeywords[index])
|
||||
}
|
||||
}
|
||||
|
||||
while (binding.keywordChips.size - 1 > newKeywords.size) {
|
||||
binding.keywordChips.removeViewAt(newKeywords.size)
|
||||
}
|
||||
|
||||
filter = filter.copy(keywords = newKeywords)
|
||||
validateSaveButton()
|
||||
}
|
||||
|
||||
private fun showAddKeywordDialog() {
|
||||
val binding = DialogFilterBinding.inflate(layoutInflater)
|
||||
binding.phraseWholeWord.isChecked = true
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.filter_keyword_addition_title)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
viewModel.addKeyword(
|
||||
FilterKeyword(
|
||||
"",
|
||||
binding.phraseEditText.text.toString(),
|
||||
binding.phraseWholeWord.isChecked
|
||||
)
|
||||
)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showEditKeywordDialog(keyword: FilterKeyword) {
|
||||
val binding = DialogFilterBinding.inflate(layoutInflater)
|
||||
binding.phraseEditText.setText(keyword.keyword)
|
||||
binding.phraseWholeWord.isChecked = keyword.wholeWord
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.filter_edit_keyword_title)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
|
||||
viewModel.modifyKeyword(
|
||||
keyword,
|
||||
keyword.copy(
|
||||
keyword = binding.phraseEditText.text.toString(),
|
||||
wholeWord = binding.phraseWholeWord.isChecked
|
||||
)
|
||||
)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun validateSaveButton() {
|
||||
binding.filterSaveButton.isEnabled = viewModel.validate()
|
||||
}
|
||||
|
||||
private fun saveChanges() {
|
||||
// TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)?
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (viewModel.saveChanges(this@EditFilterActivity)) {
|
||||
finish()
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFilter() {
|
||||
originalFilter?.let { filter ->
|
||||
lifecycleScope.launch {
|
||||
api.deleteFilter(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILTER_TO_EDIT = "FilterToEdit"
|
||||
|
||||
// Mastodon *stores* the absolute date in the filter,
|
||||
// but create/edit take a number of seconds (relative to the time the operation is posted)
|
||||
fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? {
|
||||
return when (index) {
|
||||
-1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() }
|
||||
0 -> null
|
||||
else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
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 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() {
|
||||
private var originalFilter: Filter? = null
|
||||
val title = MutableStateFlow("")
|
||||
val keywords = MutableStateFlow(listOf<FilterKeyword>())
|
||||
val action = MutableStateFlow(Filter.Action.WARN)
|
||||
val duration = MutableStateFlow(0)
|
||||
val contexts = MutableStateFlow(listOf<Filter.Kind>())
|
||||
|
||||
fun load(filter: Filter) {
|
||||
originalFilter = filter
|
||||
title.value = filter.title
|
||||
keywords.value = filter.keywords
|
||||
action.value = filter.action
|
||||
duration.value = if (filter.expiresAt == null) {
|
||||
0
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
contexts.value = filter.kinds
|
||||
}
|
||||
|
||||
fun addKeyword(keyword: FilterKeyword) {
|
||||
keywords.value += keyword
|
||||
}
|
||||
|
||||
fun deleteKeyword(keyword: FilterKeyword) {
|
||||
keywords.value = keywords.value.filterNot { it == keyword }
|
||||
}
|
||||
|
||||
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) {
|
||||
val index = keywords.value.indexOf(original)
|
||||
if (index >= 0) {
|
||||
keywords.value = keywords.value.toMutableList().apply {
|
||||
set(index, updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(title: String) {
|
||||
this.title.value = title
|
||||
}
|
||||
|
||||
fun setDuration(index: Int) {
|
||||
duration.value = index
|
||||
}
|
||||
|
||||
fun setAction(action: Filter.Action) {
|
||||
this.action.value = action
|
||||
}
|
||||
|
||||
fun addContext(context: Filter.Kind) {
|
||||
if (!contexts.value.contains(context)) {
|
||||
contexts.value += context
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContext(context: Filter.Kind) {
|
||||
contexts.value = contexts.value.filter { it != context }
|
||||
}
|
||||
|
||||
fun validate(): Boolean {
|
||||
return title.value.isNotBlank() &&
|
||||
keywords.value.isNotEmpty() &&
|
||||
contexts.value.isNotEmpty()
|
||||
}
|
||||
|
||||
suspend fun saveChanges(context: Context): Boolean {
|
||||
val contexts = contexts.value.map { it.kind }
|
||||
val title = title.value
|
||||
val durationIndex = duration.value
|
||||
val action = action.value.action
|
||||
|
||||
return withContext(viewModelScope.coroutineContext) {
|
||||
originalFilter?.let { filter ->
|
||||
updateFilter(filter, title, contexts, action, durationIndex, context)
|
||||
} ?: createFilter(title, contexts, action, durationIndex, context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createFilter(title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
|
||||
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
|
||||
api.createFilter(
|
||||
title = title,
|
||||
context = contexts,
|
||||
filterAction = action,
|
||||
expiresInSeconds = expiresInSeconds
|
||||
).fold(
|
||||
{ newFilter ->
|
||||
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||
return keywords.value.map { keyword ->
|
||||
api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
}.none { it.isFailure }
|
||||
},
|
||||
{ throwable ->
|
||||
return (
|
||||
throwable is HttpException && throwable.code() == 404 &&
|
||||
// Endpoint not found, fall back to v1 api
|
||||
createFilterV1(contexts, expiresInSeconds)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
|
||||
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
|
||||
api.updateFilter(
|
||||
id = originalFilter.id,
|
||||
title = title,
|
||||
context = contexts,
|
||||
filterAction = action,
|
||||
expiresInSeconds = expiresInSeconds
|
||||
).fold(
|
||||
{
|
||||
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||
val results = keywords.value.map { keyword ->
|
||||
if (keyword.id.isEmpty()) {
|
||||
api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
} else {
|
||||
api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
}
|
||||
} + originalFilter.keywords.filter { keyword ->
|
||||
// Deleted keywords
|
||||
keywords.value.none { it.id == keyword.id }
|
||||
}.map { api.deleteFilterKeyword(it.id) }
|
||||
|
||||
return results.none { it.isFailure }
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
// Endpoint not found, fall back to v1 api
|
||||
if (updateFilterV1(contexts, expiresInSeconds)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
|
||||
return keywords.value.map { keyword ->
|
||||
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
|
||||
}.none { it.isFailure }
|
||||
}
|
||||
|
||||
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
|
||||
val results = keywords.value.map { keyword ->
|
||||
if (originalFilter == null) {
|
||||
api.createFilterV1(
|
||||
phrase = keyword.keyword,
|
||||
context = context,
|
||||
irreversible = false,
|
||||
wholeWord = keyword.wholeWord,
|
||||
expiresInSeconds = expiresInSeconds
|
||||
)
|
||||
} else {
|
||||
api.updateFilterV1(
|
||||
id = originalFilter!!.id,
|
||||
phrase = keyword.keyword,
|
||||
context = context,
|
||||
irreversible = false,
|
||||
wholeWord = keyword.wholeWord,
|
||||
expiresInSeconds = expiresInSeconds
|
||||
)
|
||||
}
|
||||
}
|
||||
// Don't handle deleted keywords here because there's only one keyword per v1 filter anyway
|
||||
|
||||
return results.none { it.isFailure }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
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.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class FiltersActivity : BaseActivity(), FiltersListener {
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val binding by viewBinding(ActivityFiltersBinding::inflate)
|
||||
private val viewModel: FiltersViewModel by viewModels { viewModelFactory }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
supportActionBar?.run {
|
||||
// Back button
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
binding.addFilterButton.setOnClickListener {
|
||||
launchEditFilterActivity()
|
||||
}
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() }
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
|
||||
setTitle(R.string.pref_title_timeline_filters)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
loadFilters()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
binding.progressBar.visible(state.loadingState == FiltersViewModel.LoadingState.LOADING)
|
||||
binding.swipeRefreshLayout.isRefreshing = state.loadingState == FiltersViewModel.LoadingState.LOADING
|
||||
binding.addFilterButton.visible(state.loadingState == FiltersViewModel.LoadingState.LOADED)
|
||||
|
||||
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) {
|
||||
loadFilters()
|
||||
}
|
||||
binding.messageView.show()
|
||||
}
|
||||
FiltersViewModel.LoadingState.ERROR_OTHER -> {
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
loadFilters()
|
||||
}
|
||||
binding.messageView.show()
|
||||
}
|
||||
FiltersViewModel.LoadingState.LOADED -> {
|
||||
if (state.filters.isEmpty()) {
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
binding.messageView.show()
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFilters() {
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
private fun launchEditFilterActivity(filter: Filter? = null) {
|
||||
val intent = Intent(this, EditFilterActivity::class.java).apply {
|
||||
if (filter != null) {
|
||||
putExtra(EditFilterActivity.FILTER_TO_EDIT, filter)
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
|
||||
}
|
||||
|
||||
override fun deleteFilter(filter: Filter) {
|
||||
viewModel.deleteFilter(filter, binding.root)
|
||||
}
|
||||
|
||||
override fun updateFilter(updatedFilter: Filter) {
|
||||
launchEditFilterActivity(updatedFilter)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemRemovableBinding
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
|
||||
class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
|
||||
RecyclerView.Adapter<BindingHolder<ItemRemovableBinding>>() {
|
||||
|
||||
override fun getItemCount(): Int = filters.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemRemovableBinding> {
|
||||
return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemRemovableBinding>, position: Int) {
|
||||
val binding = holder.binding
|
||||
val resources = binding.root.resources
|
||||
val actions = resources.getStringArray(R.array.filter_actions)
|
||||
val contexts = resources.getStringArray(R.array.filter_contexts)
|
||||
|
||||
val filter = filters[position]
|
||||
val context = binding.root.context
|
||||
binding.textPrimary.text = if (filter.expiresAt == null) {
|
||||
filter.title
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.filter_expiration_format,
|
||||
filter.title,
|
||||
getRelativeTimeSpanString(binding.root.context, filter.expiresAt.time, System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
binding.textSecondary.text = context.getString(
|
||||
R.string.filter_description_format,
|
||||
actions.getOrNull(filter.action.ordinal - 1),
|
||||
filter.context.map { contexts.getOrNull(Filter.Kind.from(it).ordinal) }.joinToString("/")
|
||||
)
|
||||
|
||||
binding.delete.setOnClickListener {
|
||||
listener.deleteFilter(filter)
|
||||
}
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
listener.updateFilter(filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
|
||||
interface FiltersListener {
|
||||
fun deleteFilter(filter: Filter)
|
||||
fun updateFilter(updatedFilter: Filter)
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.entity.Filter
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
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(
|
||||
private val api: MastodonApi,
|
||||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
||||
data class State(val filters: List<Filter>, val loadingState: LoadingState)
|
||||
|
||||
val state: Flow<State> get() = _state
|
||||
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
|
||||
|
||||
fun load() {
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
|
||||
|
||||
viewModelScope.launch {
|
||||
api.getFilters().fold(
|
||||
{ filters ->
|
||||
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
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)
|
||||
}
|
||||
)
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
|
||||
} else {
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFilter(filter: Filter, parent: View) {
|
||||
viewModelScope.launch {
|
||||
api.deleteFilter(filter.id).fold(
|
||||
{
|
||||
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
|
||||
for (context in filter.context) {
|
||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
|
||||
},
|
||||
{
|
||||
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +1,51 @@
|
|||
package com.keylesspalace.tusky.components.followedtags
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.AutoCompleteTextView
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.HashtagActionListener
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
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 java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
|
||||
class FollowedTagsActivity :
|
||||
BaseActivity(),
|
||||
HashtagActionListener,
|
||||
ComposeAutoCompleteAdapter.AutocompletionProvider {
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var sharedPreferences: SharedPreferences
|
||||
|
||||
private val binding by viewBinding(ActivityFollowedTagsBinding::inflate)
|
||||
private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
|
|
@ -47,6 +61,11 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
|
|||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
binding.fab.setOnClickListener {
|
||||
val dialog: DialogFragment = FollowTagDialog.newInstance()
|
||||
dialog.show(supportFragmentManager, "dialog")
|
||||
}
|
||||
|
||||
setupAdapter().let { adapter ->
|
||||
setupRecyclerView(adapter)
|
||||
|
||||
|
|
@ -64,6 +83,19 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
|
|||
binding.followedTagsView.layoutManager = LinearLayoutManager(this)
|
||||
binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
|
||||
(binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
val hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
if (hideFab) {
|
||||
binding.followedTagsView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
if (dy > 0 && binding.fab.isShown) {
|
||||
binding.fab.hide()
|
||||
} else if (dy < 0 && !binding.fab.isShown) {
|
||||
binding.fab.show()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAdapter(): FollowedTagsAdapter {
|
||||
|
|
@ -75,11 +107,7 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
|
|||
binding.followedTagsView.hide()
|
||||
binding.followedTagsMessageView.show()
|
||||
val errorState = loadState.refresh as LoadState.Error
|
||||
if (errorState.error is IOException) {
|
||||
binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() }
|
||||
} else {
|
||||
binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() }
|
||||
}
|
||||
binding.followedTagsMessageView.setup(errorState.error) { retry() }
|
||||
Log.w(TAG, "error loading followed hashtags", errorState.error)
|
||||
} else {
|
||||
binding.followedTagsView.show()
|
||||
|
|
@ -89,11 +117,15 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
|
|||
}
|
||||
}
|
||||
|
||||
private fun follow(tagName: String, position: Int) {
|
||||
private fun follow(tagName: String, position: Int = -1) {
|
||||
lifecycleScope.launch {
|
||||
api.followTag(tagName).fold(
|
||||
{
|
||||
viewModel.tags.add(position, it)
|
||||
if (position == -1) {
|
||||
viewModel.tags.add(it)
|
||||
} else {
|
||||
viewModel.tags.add(position, it)
|
||||
}
|
||||
viewModel.currentSource?.invalidate()
|
||||
},
|
||||
{
|
||||
|
|
@ -142,7 +174,41 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
|
|||
}
|
||||
}
|
||||
|
||||
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return viewModel.searchAutocompleteSuggestions(token)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "FollowedTagsActivity"
|
||||
}
|
||||
|
||||
class FollowTagDialog : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val layout = layoutInflater.inflate(R.layout.dialog_follow_hashtag, null)
|
||||
val autoCompleteTextView = layout.findViewById<AutoCompleteTextView>(R.id.hashtag)!!
|
||||
autoCompleteTextView.setAdapter(
|
||||
ComposeAutoCompleteAdapter(
|
||||
requireActivity() as FollowedTagsActivity,
|
||||
animateAvatar = false,
|
||||
animateEmojis = false,
|
||||
showBotBadge = false
|
||||
)
|
||||
)
|
||||
|
||||
return AlertDialog.Builder(requireActivity())
|
||||
.setTitle(R.string.dialog_follow_hashtag_title)
|
||||
.setView(layout)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
(requireActivity() as FollowedTagsActivity).follow(
|
||||
autoCompleteTextView.text.toString().removePrefix("#")
|
||||
)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> }
|
||||
.create()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(): FollowTagDialog = FollowTagDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import com.keylesspalace.tusky.util.BindingHolder
|
|||
|
||||
class FollowedTagsAdapter(
|
||||
private val actionListener: HashtagActionListener,
|
||||
private val viewModel: FollowedTagsViewModel,
|
||||
private val viewModel: FollowedTagsViewModel
|
||||
) : PagingDataAdapter<String, BindingHolder<ItemFollowedHashtagBinding>>(STRING_COMPARATOR) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowedHashtagBinding> =
|
||||
BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
|
|
@ -22,7 +22,7 @@ class FollowedTagsAdapter(
|
|||
viewModel.tags[position].let { tag ->
|
||||
holder.itemView.findViewById<TextView>(R.id.followed_tag).text = tag.name
|
||||
holder.itemView.findViewById<ImageButton>(R.id.followed_tag_unfollow).setOnClickListener {
|
||||
actionListener.unfollow(tag.name, position)
|
||||
actionListener.unfollow(tag.name, holder.bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import retrofit2.Response
|
|||
@OptIn(ExperimentalPagingApi::class)
|
||||
class FollowedTagsRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val viewModel: FollowedTagsViewModel,
|
||||
private val viewModel: FollowedTagsViewModel
|
||||
) : RemoteMediator<String, String>() {
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
package com.keylesspalace.tusky.components.followedtags
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import javax.inject.Inject
|
||||
|
||||
class FollowedTagsViewModel @Inject constructor (
|
||||
api: MastodonApi
|
||||
class FollowedTagsViewModel @Inject constructor(
|
||||
private val api: MastodonApi
|
||||
) : ViewModel(), Injectable {
|
||||
val tags: MutableList<HashTag> = mutableListOf()
|
||||
var nextKey: String? = null
|
||||
|
|
@ -28,6 +32,20 @@ class FollowedTagsViewModel @Inject constructor (
|
|||
).also { source ->
|
||||
currentSource = source
|
||||
}
|
||||
},
|
||||
}
|
||||
).flow.cachedIn(viewModelScope)
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map { ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(it.name) }
|
||||
}, { e ->
|
||||
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||
emptyList()
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FollowedTagsViewModel"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ class DomainMutesAdapter(
|
|||
|
||||
override fun getItemCount(): Int {
|
||||
var count = instances.size
|
||||
if (bottomLoading)
|
||||
if (bottomLoading) {
|
||||
++count
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ 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
|
||||
|
|
@ -23,10 +25,7 @@ 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 retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
|
||||
|
|
@ -64,39 +63,25 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab
|
|||
}
|
||||
|
||||
override fun mute(mute: Boolean, instance: String, position: Int) {
|
||||
if (mute) {
|
||||
api.blockDomain(instance).enqueue(object : Callback<Any> {
|
||||
override fun onFailure(call: Call<Any>, t: Throwable) {
|
||||
Log.e(TAG, "Error muting domain $instance")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<Any>, response: Response<Any>) {
|
||||
if (response.isSuccessful) {
|
||||
adapter.addItem(instance)
|
||||
} else {
|
||||
Log.e(TAG, "Error muting domain $instance")
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
api.unblockDomain(instance).enqueue(object : Callback<Any> {
|
||||
override fun onFailure(call: Call<Any>, t: Throwable) {
|
||||
Log.e(TAG, "Error unmuting domain $instance")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<Any>, response: Response<Any>) {
|
||||
if (response.isSuccessful) {
|
||||
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()
|
||||
} else {
|
||||
Log.e(TAG, "Error unmuting domain $instance")
|
||||
}
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,16 +145,9 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab
|
|||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
if (throwable is IOException) {
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
} else {
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
binding.messageView.setup(throwable) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
|
||||
preferences = getSharedPreferences(
|
||||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE
|
||||
getString(R.string.preferences_file_key),
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
binding.loginButton.setOnClickListener { onLoginClick(true) }
|
||||
|
|
@ -182,8 +183,11 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.authenticateApp(
|
||||
domain, getString(R.string.app_name), oauthRedirectUri,
|
||||
OAUTH_SCOPES, getString(R.string.tusky_website)
|
||||
domain,
|
||||
getString(R.string.app_name),
|
||||
oauthRedirectUri,
|
||||
OAUTH_SCOPES,
|
||||
getString(R.string.tusky_website)
|
||||
).fold(
|
||||
{ credentials ->
|
||||
// Before we open browser page we save the data.
|
||||
|
|
@ -287,7 +291,12 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
setLoading(true)
|
||||
|
||||
mastodonApi.fetchOAuthToken(
|
||||
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
|
||||
domain,
|
||||
clientId,
|
||||
clientSecret,
|
||||
oauthRedirectUri,
|
||||
code,
|
||||
"authorization_code"
|
||||
).fold(
|
||||
{ accessToken ->
|
||||
fetchAccountDetails(accessToken, domain, clientId, clientSecret)
|
||||
|
|
@ -307,7 +316,6 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
clientId: String,
|
||||
clientSecret: String
|
||||
) {
|
||||
|
||||
mastodonApi.accountVerifyCredentials(
|
||||
domain = domain,
|
||||
auth = "Bearer ${accessToken.accessToken}"
|
||||
|
|
@ -363,6 +371,7 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
|
||||
const val MODE_DEFAULT = 0
|
||||
const val MODE_ADDITIONAL_LOGIN = 1
|
||||
|
||||
// "Migration" is used to update the OAuth scope granted to the client
|
||||
const val MODE_MIGRATION = 2
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import android.webkit.WebViewClient
|
|||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
|
@ -61,7 +62,9 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
|
|||
return if (resultCode == Activity.RESULT_CANCELED) {
|
||||
LoginResult.Cancel
|
||||
} else {
|
||||
intent!!.getParcelableExtra(RESULT_EXTRA)!!
|
||||
intent?.let {
|
||||
IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java)
|
||||
} ?: LoginResult.Err("failed parsing LoginWebViewActivity result")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +73,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
|
|||
private const val DATA_EXTRA = "data"
|
||||
|
||||
fun parseData(intent: Intent): LoginData {
|
||||
return intent.getParcelableExtra(DATA_EXTRA)!!
|
||||
return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!!
|
||||
}
|
||||
|
||||
fun makeResultIntent(result: LoginResult): Intent {
|
||||
|
|
@ -85,7 +88,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
|
|||
data class LoginData(
|
||||
val domain: String,
|
||||
val url: Uri,
|
||||
val oauthRedirectUrl: Uri,
|
||||
val oauthRedirectUrl: Uri
|
||||
) : Parcelable
|
||||
|
||||
sealed class LoginResult : Parcelable {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +1,190 @@
|
|||
package com.keylesspalace.tusky.components.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
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.isLessThan
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Fetch Mastodon notifications and show Android notifications, with summaries, for them.
|
||||
*
|
||||
* Should only be called by a worker thread.
|
||||
*
|
||||
* @see NotificationWorker
|
||||
* @see <a href="https://developer.android.com/guide/background/persistent/threading/worker">Background worker</a>
|
||||
*/
|
||||
@WorkerThread
|
||||
class NotificationFetcher @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val context: Context
|
||||
) {
|
||||
fun fetchAndShow() {
|
||||
suspend fun fetchAndShow() {
|
||||
for (account in accountManager.getAllAccountsOrderedByActive()) {
|
||||
if (account.notificationsEnabled) {
|
||||
try {
|
||||
val notifications = fetchNotifications(account)
|
||||
notifications.forEachIndexed { index, notification ->
|
||||
NotificationHelper.make(context, notification, account, index == 0)
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Create sorted list of new notifications
|
||||
val notifications = fetchNewNotifications(account)
|
||||
.filter { filterNotification(notificationManager, account, it) }
|
||||
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
|
||||
.toMutableList()
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Err on the side of removing *older* notifications to make room for newer
|
||||
// notifications.
|
||||
val currentAndroidNotifications = notificationManager.activeNotifications
|
||||
.sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first
|
||||
|
||||
// Check to see if any notifications need to be removed
|
||||
val toRemove = currentAndroidNotifications.size + notifications.size - MAX_NOTIFICATIONS
|
||||
if (toRemove > 0) {
|
||||
// Prefer to cancel old notifications first
|
||||
currentAndroidNotifications.subList(0, min(toRemove, currentAndroidNotifications.size))
|
||||
.forEach { notificationManager.cancel(it.tag, it.id) }
|
||||
|
||||
// Still got notifications to remove? Trim the list of new notifications,
|
||||
// starting with the oldest.
|
||||
while (notifications.size > MAX_NOTIFICATIONS) {
|
||||
notifications.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
val androidNotification = NotificationHelper.make(
|
||||
context,
|
||||
notificationManager,
|
||||
notification,
|
||||
account,
|
||||
index == 0
|
||||
)
|
||||
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,
|
||||
notificationManager,
|
||||
account
|
||||
)
|
||||
|
||||
accountManager.saveAccount(account)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error while fetching notifications", e)
|
||||
Log.e(TAG, "Error while fetching notifications", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchNotifications(account: AccountEntity): MutableList<Notification> {
|
||||
/**
|
||||
* Fetch new Mastodon Notifications and update the marker position.
|
||||
*
|
||||
* Here, "new" means "notifications with IDs newer than notifications the user has already
|
||||
* seen."
|
||||
*
|
||||
* The "water mark" for Mastodon Notification IDs are stored in three places.
|
||||
*
|
||||
* - acccount.lastNotificationId -- the ID of the top-most notification when the user last
|
||||
* left the Notifications tab.
|
||||
* - The Mastodon "marker" API -- the ID of the most recent notification fetched here.
|
||||
* - account.notificationMarkerId -- local version of the value from the Mastodon marker
|
||||
* API, in case the Mastodon server does not implement that API.
|
||||
*
|
||||
* The user may have refreshed the "Notifications" tab and seen notifications newer than the
|
||||
* ones that were last fetched here. So `lastNotificationId` takes precedence if it is greater
|
||||
* than the marker.
|
||||
*/
|
||||
private suspend fun fetchNewNotifications(account: AccountEntity): List<Notification> {
|
||||
val authHeader = String.format("Bearer %s", account.accessToken)
|
||||
// We fetch marker to not load/show notifications which user has already seen
|
||||
val marker = fetchMarker(authHeader, account)
|
||||
if (marker != null && account.lastNotificationId.isLessThan(marker.lastReadId)) {
|
||||
account.lastNotificationId = marker.lastReadId
|
||||
}
|
||||
Log.d(TAG, "getting Notifications for " + account.fullName)
|
||||
val notifications = mastodonApi.notificationsWithAuth(
|
||||
authHeader,
|
||||
account.domain,
|
||||
account.lastNotificationId
|
||||
).blockingGet()
|
||||
|
||||
val newId = account.lastNotificationId
|
||||
var newestId = ""
|
||||
val result = mutableListOf<Notification>()
|
||||
for (notification in notifications.reversed()) {
|
||||
val currentId = notification.id
|
||||
if (newestId.isLessThan(currentId)) {
|
||||
newestId = currentId
|
||||
account.lastNotificationId = currentId
|
||||
}
|
||||
if (newId.isLessThan(currentId)) {
|
||||
result.add(notification)
|
||||
// Figure out where to read from. Choose the most recent notification ID from:
|
||||
//
|
||||
// - The Mastodon marker API (if the server supports it)
|
||||
// - account.notificationMarkerId
|
||||
// - account.lastNotificationId
|
||||
Log.d(TAG, "getting notification marker for ${account.fullName}")
|
||||
val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0"
|
||||
val localMarkerId = account.notificationMarkerId
|
||||
val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId
|
||||
val readingPosition = account.lastNotificationId
|
||||
|
||||
var minId: String? = if (readingPosition.isLessThan(markerId)) markerId else readingPosition
|
||||
Log.d(TAG, " remoteMarkerId: $remoteMarkerId")
|
||||
Log.d(TAG, " localMarkerId: $localMarkerId")
|
||||
Log.d(TAG, " readingPosition: $readingPosition")
|
||||
|
||||
Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId")
|
||||
|
||||
// Fetch all outstanding notifications
|
||||
val notifications = buildList {
|
||||
while (minId != null) {
|
||||
val response = mastodonApi.notificationsWithAuth(
|
||||
authHeader,
|
||||
account.domain,
|
||||
minId = minId
|
||||
)
|
||||
if (!response.isSuccessful) break
|
||||
|
||||
// Notifications are returned in the page in order, newest first,
|
||||
// (https://github.com/mastodon/documentation/issues/1226), insert the
|
||||
// new page at the head of the list.
|
||||
response.body()?.let { addAll(0, it) }
|
||||
|
||||
// Get the previous page, which will be chronologically newer
|
||||
// notifications. If it doesn't exist this is null and the loop
|
||||
// will exit.
|
||||
val links = Links.from(response.headers()["link"])
|
||||
minId = links.prev
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
// Save the newest notification ID in the marker.
|
||||
notifications.firstOrNull()?.let {
|
||||
val newMarkerId = notifications.first().id
|
||||
Log.d(TAG, "updating notification marker for ${account.fullName} to: $newMarkerId")
|
||||
mastodonApi.updateMarkersWithAuth(
|
||||
auth = authHeader,
|
||||
domain = account.domain,
|
||||
notificationsLastReadId = newMarkerId
|
||||
)
|
||||
account.notificationMarkerId = newMarkerId
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
|
||||
private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
|
||||
return try {
|
||||
val allMarkers = mastodonApi.markersWithAuth(
|
||||
authHeader,
|
||||
account.domain,
|
||||
listOf("notifications")
|
||||
).blockingGet()
|
||||
)
|
||||
val notificationMarker = allMarkers["notifications"]
|
||||
Log.d(TAG, "Fetched marker: $notificationMarker")
|
||||
Log.d(TAG, "Fetched marker for ${account.fullName}: $notificationMarker")
|
||||
notificationMarker
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch marker", e)
|
||||
|
|
@ -78,6 +193,13 @@ class NotificationFetcher @Inject constructor(
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NotificationFetcher"
|
||||
private const val TAG = "NotificationFetcher"
|
||||
|
||||
// There's a system limit on the maximum number of notifications an app
|
||||
// can show, NotificationManagerService.MAX_PACKAGE_NOTIFICATIONS. Unfortunately
|
||||
// that's not available to client code or via the NotificationManager API.
|
||||
// The current value in the Android source code is 50, set 40 here to both
|
||||
// be conservative, and allow some headroom for summary notifications.
|
||||
private const val MAX_NOTIFICATIONS = 40
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.notifications;
|
||||
|
||||
import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
|
||||
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
|
||||
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
|
||||
|
||||
|
|
@ -28,18 +29,22 @@ import android.content.Intent;
|
|||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.app.RemoteInput;
|
||||
import androidx.core.app.TaskStackBuilder;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.OutOfQuotaPolicy;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.WorkRequest;
|
||||
|
|
@ -56,35 +61,36 @@ import com.keylesspalace.tusky.entity.Notification;
|
|||
import com.keylesspalace.tusky.entity.Poll;
|
||||
import com.keylesspalace.tusky.entity.PollOption;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import com.keylesspalace.tusky.worker.NotificationWorker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class NotificationHelper {
|
||||
|
||||
private static int notificationId = 0;
|
||||
/** ID of notification shown when fetching notifications */
|
||||
public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
|
||||
/** ID of notification shown when pruning the cache */
|
||||
public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
|
||||
/** Dynamic notification IDs start here */
|
||||
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
|
||||
|
||||
/**
|
||||
* constants used in Intents
|
||||
*/
|
||||
public static final String ACCOUNT_ID = "account_id";
|
||||
|
||||
public static final String TYPE = "type";
|
||||
public static final String TYPE = APPLICATION_ID + ".notification.type";
|
||||
|
||||
private static final String TAG = "NotificationHelper";
|
||||
|
||||
|
|
@ -121,57 +127,60 @@ public class NotificationHelper {
|
|||
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
|
||||
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
|
||||
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
|
||||
public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
|
||||
|
||||
/**
|
||||
* WorkManager Tag
|
||||
*/
|
||||
private static final String NOTIFICATION_PULL_TAG = "pullNotifications";
|
||||
|
||||
/** Tag for the summary notification */
|
||||
private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary";
|
||||
|
||||
/** The name of the account that caused the notification, for use in a summary */
|
||||
private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name";
|
||||
|
||||
/** The notification's type (string representation of a Notification.Type) */
|
||||
private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type";
|
||||
|
||||
/**
|
||||
* Takes a given Mastodon notification and either creates a new Android notification or updates
|
||||
* the state of the existing notification to reflect the new interaction.
|
||||
* Takes a given Mastodon notification and creates a new Android notification or updates the
|
||||
* existing Android notification.
|
||||
* <p>
|
||||
* The Android notification has it's tag set to the Mastodon notification ID, and it's ID set
|
||||
* to the ID of the account that received the notification.
|
||||
*
|
||||
* @param context to access application preferences and services
|
||||
* @param body a new Mastodon notification
|
||||
* @param account the account for which the notification should be shown
|
||||
* @return the new notification
|
||||
*/
|
||||
|
||||
public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) {
|
||||
@NonNull
|
||||
public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) {
|
||||
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
|
||||
String mastodonNotificationId = body.getId();
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
if (!filterNotification(account, body, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String rawCurrentNotifications = account.getActiveNotifications();
|
||||
JSONArray currentNotifications;
|
||||
|
||||
try {
|
||||
currentNotifications = new JSONArray(rawCurrentNotifications);
|
||||
} catch (JSONException e) {
|
||||
currentNotifications = new JSONArray();
|
||||
}
|
||||
|
||||
for (int i = 0; i < currentNotifications.length(); i++) {
|
||||
try {
|
||||
if (currentNotifications.getString(i).equals(body.getAccount().getName())) {
|
||||
currentNotifications.remove(i);
|
||||
break;
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.d(TAG, Log.getStackTraceString(e));
|
||||
// Check for an existing notification with this Mastodon Notification ID
|
||||
android.app.Notification existingAndroidNotification = null;
|
||||
StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications();
|
||||
for (StatusBarNotification androidNotification : activeNotifications) {
|
||||
if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) {
|
||||
existingAndroidNotification = androidNotification.getNotification();
|
||||
}
|
||||
}
|
||||
|
||||
currentNotifications.put(body.getAccount().getName());
|
||||
|
||||
account.setActiveNotifications(currentNotifications.toString());
|
||||
|
||||
// Notification group member
|
||||
// =========================
|
||||
final NotificationCompat.Builder builder = newNotification(context, body, account, false);
|
||||
|
||||
notificationId++;
|
||||
// Create the notification -- either create a new one, or use the existing one.
|
||||
NotificationCompat.Builder builder;
|
||||
if (existingAndroidNotification == null) {
|
||||
builder = newAndroidNotification(context, body, account);
|
||||
} else {
|
||||
builder = new NotificationCompat.Builder(context, existingAndroidNotification);
|
||||
}
|
||||
|
||||
builder.setContentTitle(titleForType(context, body, account))
|
||||
.setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler()));
|
||||
|
|
@ -233,51 +242,136 @@ public class NotificationHelper {
|
|||
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
|
||||
builder.setOnlyAlertOnce(true);
|
||||
|
||||
// only alert for the first notification of a batch to avoid multiple alerts at once
|
||||
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());
|
||||
builder.addExtras(extras);
|
||||
|
||||
// Only alert for the first notification of a batch to avoid multiple alerts at once
|
||||
if(!isFirstOfBatch) {
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
|
||||
}
|
||||
|
||||
// Summary
|
||||
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
if (currentNotifications.length() != 1) {
|
||||
try {
|
||||
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, currentNotifications.length(), currentNotifications.length());
|
||||
String text = joinNames(context, currentNotifications);
|
||||
summaryBuilder.setContentTitle(title)
|
||||
.setContentText(text);
|
||||
} catch (JSONException e) {
|
||||
Log.d(TAG, Log.getStackTraceString(e));
|
||||
}
|
||||
/**
|
||||
* Updates the summary notifications for each notification group.
|
||||
* <p>
|
||||
* Notifications are sent to channels. Within each channel they may be grouped, and the group
|
||||
* may have a summary.
|
||||
* <p>
|
||||
* Tusky uses N notification channels for each account, each channel corresponds to a type
|
||||
* of notification (follow, reblog, mention, etc). Therefore each channel also has exactly
|
||||
* 0 or 1 summary notifications along with its regular notifications.
|
||||
* <p>
|
||||
* The group key is the same as the channel ID.
|
||||
* <p>
|
||||
* Regnerates the summary notifications for all active Tusky notifications for `account`.
|
||||
* This may delete the summary notification if there are no active notifications for that
|
||||
* account in a group.
|
||||
*
|
||||
* @see <a href="https://developer.android.com/develop/ui/views/notifications/group">Create a
|
||||
* notification group</a>
|
||||
* @param context to access application preferences and services
|
||||
* @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) {
|
||||
// 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()) {
|
||||
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
|
||||
}
|
||||
|
||||
summaryBuilder.setSubText(account.getFullName());
|
||||
summaryBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
|
||||
summaryBuilder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
|
||||
summaryBuilder.setOnlyAlertOnce(true);
|
||||
summaryBuilder.setGroupSummary(true);
|
||||
// Fetch all existing notifications. Add them to the map, ignoring notifications that:
|
||||
// - belong to a different account
|
||||
// - are summary notifications
|
||||
for (StatusBarNotification sn : notificationManager.getActiveNotifications()) {
|
||||
if (sn.getId() != accountId) continue;
|
||||
|
||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
|
||||
String channelId = sn.getNotification().getGroup();
|
||||
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
|
||||
if (summaryTag.equals(sn.getTag())) continue;
|
||||
|
||||
notificationManager.notify(notificationId, builder.build());
|
||||
if (currentNotifications.length() == 1) {
|
||||
notificationManager.notify((int) account.getId(), builder.setGroupSummary(true).build());
|
||||
} else {
|
||||
notificationManager.notify((int) account.getId(), summaryBuilder.build());
|
||||
// TODO: API 26 supports getting the channel ID directly (sn.getNotification().getChannelId()).
|
||||
// This works here because the channelId and the groupKey are the same.
|
||||
List<StatusBarNotification> members = channelGroups.get(channelId);
|
||||
if (members == null) { // can't happen, but just in case...
|
||||
Log.e(TAG, "members == null for channel ID " + channelId);
|
||||
continue;
|
||||
}
|
||||
members.add(sn);
|
||||
}
|
||||
|
||||
// Create, update, or cancel the summary notifications for each group.
|
||||
for (Map.Entry<String, List<StatusBarNotification>> channelGroup : channelGroups.entrySet()) {
|
||||
String channelId = channelGroup.getKey();
|
||||
List<StatusBarNotification> members = channelGroup.getValue();
|
||||
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
|
||||
|
||||
// If there are 0-1 notifications in this group then the additional summary
|
||||
// notification is not needed and can be cancelled.
|
||||
if (members.size() <= 1) {
|
||||
notificationManager.cancel(summaryTag, accountId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
|
||||
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
|
||||
pendingIntentFlags(false));
|
||||
|
||||
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, members.size(), members.size());
|
||||
String text = joinNames(context, members);
|
||||
|
||||
NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(summaryResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setAutoCancel(true)
|
||||
.setShortcutId(Long.toString(account.getId()))
|
||||
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSubText(account.getFullName())
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(channelId)
|
||||
.setGroupSummary(true);
|
||||
|
||||
setSoundVibrationLight(account, summaryBuilder);
|
||||
|
||||
// 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.
|
||||
notificationManager.notify(summaryTag, accountId, summaryBuilder.build());
|
||||
|
||||
// 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
|
||||
try { Thread.sleep(1000); } catch (InterruptedException ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) {
|
||||
Intent summaryResultIntent = new Intent(context, MainActivity.class);
|
||||
summaryResultIntent.putExtra(ACCOUNT_ID, account.getId());
|
||||
summaryResultIntent.putExtra(TYPE, body.getType().name());
|
||||
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
|
||||
summaryStackBuilder.addParentStack(MainActivity.class);
|
||||
summaryStackBuilder.addNextIntent(summaryResultIntent);
|
||||
|
||||
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
|
||||
pendingIntentFlags(false));
|
||||
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
|
||||
|
||||
// we have to switch account here
|
||||
Intent eventResultIntent = new Intent(context, MainActivity.class);
|
||||
|
|
@ -290,22 +384,19 @@ public class NotificationHelper {
|
|||
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
|
||||
pendingIntentFlags(false));
|
||||
|
||||
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
|
||||
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
|
||||
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent,
|
||||
pendingIntentFlags(false));
|
||||
String channelId = getChannelId(account, body);
|
||||
assert channelId != null;
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
|
||||
.setDeleteIntent(deletePendingIntent)
|
||||
.setContentIntent(eventResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setGroup(account.getAccountId())
|
||||
.setGroup(channelId)
|
||||
.setAutoCancel(true)
|
||||
.setShortcutId(Long.toString(account.getId()))
|
||||
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
|
||||
|
||||
setupPreferences(account, builder);
|
||||
setSoundVibrationLight(account, builder);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
|
@ -388,6 +479,49 @@ public class NotificationHelper {
|
|||
pendingIntentFlags(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification channel for notifications for background work that should not
|
||||
* disturb the user.
|
||||
*
|
||||
* @param context context
|
||||
*/
|
||||
public static void createWorkerNotificationChannel(@NonNull Context context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_BACKGROUND_TASKS,
|
||||
context.getString(R.string.notification_listenable_worker_name),
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
);
|
||||
|
||||
channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
|
||||
channel.enableLights(false);
|
||||
channel.enableVibration(false);
|
||||
channel.setShowBadge(false);
|
||||
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification for a background worker.
|
||||
*
|
||||
* @param context context
|
||||
* @param titleResource String resource to use as the notification's title
|
||||
* @return the notification
|
||||
*/
|
||||
@NonNull
|
||||
public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
|
||||
String title = context.getString(titleResource);
|
||||
return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
|
|
@ -453,7 +587,6 @@ public class NotificationHelper {
|
|||
}
|
||||
|
||||
notificationManager.createNotificationChannels(channels);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -495,6 +628,15 @@ public class NotificationHelper {
|
|||
WorkManager workManager = WorkManager.getInstance(context);
|
||||
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
|
||||
|
||||
// Periodic work requests are supposed to start running soon after being enqueued. In
|
||||
// practice that may not be soon enough, so create and enqueue an expedited one-time
|
||||
// request to get new notifications immediately.
|
||||
WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class)
|
||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build();
|
||||
workManager.enqueue(fetchNotifications);
|
||||
|
||||
WorkRequest workRequest = new PeriodicWorkRequest.Builder(
|
||||
NotificationWorker.class,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS,
|
||||
|
|
@ -502,6 +644,7 @@ public class NotificationHelper {
|
|||
)
|
||||
.addTag(NOTIFICATION_PULL_TAG)
|
||||
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setInitialDelay(5, TimeUnit.MINUTES)
|
||||
.build();
|
||||
|
||||
workManager.enqueue(workRequest);
|
||||
|
|
@ -514,33 +657,23 @@ public class NotificationHelper {
|
|||
Log.d(TAG, "disabled notification checks");
|
||||
}
|
||||
|
||||
public static void clearNotificationsForActiveAccount(@NonNull Context context, @NonNull AccountManager accountManager) {
|
||||
AccountEntity account = accountManager.getActiveAccount();
|
||||
if (account != null && !account.getActiveNotifications().equals("[]")) {
|
||||
Single.fromCallable(() -> {
|
||||
account.setActiveNotifications("[]");
|
||||
accountManager.saveAccount(account);
|
||||
public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) {
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancel((int) account.getId());
|
||||
return true;
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe();
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) {
|
||||
if (accountId == androidNotification.getId()) {
|
||||
notificationManager.cancel(androidNotification.getTag(), androidNotification.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean filterNotification(AccountEntity account, Notification notification,
|
||||
Context context) {
|
||||
return filterNotification(account, notification.getType(), context);
|
||||
public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) {
|
||||
return filterNotification(notificationManager, account, notification.getType());
|
||||
}
|
||||
|
||||
public static boolean filterNotification(AccountEntity account, Notification.Type type,
|
||||
Context context) {
|
||||
|
||||
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
String channelId = getChannelId(account, type);
|
||||
if(channelId == null) {
|
||||
// unknown notificationtype
|
||||
|
|
@ -610,9 +743,7 @@ public class NotificationHelper {
|
|||
|
||||
}
|
||||
|
||||
private static void setupPreferences(AccountEntity account,
|
||||
NotificationCompat.Builder builder) {
|
||||
|
||||
private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
return; //do nothing on Android O or newer, the system uses the channel settings anyway
|
||||
}
|
||||
|
|
@ -630,28 +761,29 @@ public class NotificationHelper {
|
|||
}
|
||||
}
|
||||
|
||||
private static String wrapItemAt(JSONArray array, int index) throws JSONException {
|
||||
return StringUtils.unicodeWrap(array.get(index).toString());
|
||||
private static String wrapItemAt(StatusBarNotification notification) {
|
||||
return StringUtils.unicodeWrap(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));//getAccount().getName());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String joinNames(Context context, JSONArray array) throws JSONException {
|
||||
if (array.length() > 3) {
|
||||
int length = array.length();
|
||||
private static String joinNames(Context context, List<StatusBarNotification> notifications) {
|
||||
if (notifications.size() > 3) {
|
||||
int length = notifications.size();
|
||||
//notifications.get(0).getNotification().extras.getString(EXTRA_ACCOUNT_NAME);
|
||||
return String.format(context.getString(R.string.notification_summary_large),
|
||||
wrapItemAt(array, length - 1),
|
||||
wrapItemAt(array, length - 2),
|
||||
wrapItemAt(array, length - 3),
|
||||
wrapItemAt(notifications.get(length - 1)),
|
||||
wrapItemAt(notifications.get(length - 2)),
|
||||
wrapItemAt(notifications.get(length - 3)),
|
||||
length - 3);
|
||||
} else if (array.length() == 3) {
|
||||
} else if (notifications.size() == 3) {
|
||||
return String.format(context.getString(R.string.notification_summary_medium),
|
||||
wrapItemAt(array, 2),
|
||||
wrapItemAt(array, 1),
|
||||
wrapItemAt(array, 0));
|
||||
} else if (array.length() == 2) {
|
||||
wrapItemAt(notifications.get(2)),
|
||||
wrapItemAt(notifications.get(1)),
|
||||
wrapItemAt(notifications.get(0)));
|
||||
} else if (notifications.size() == 2) {
|
||||
return String.format(context.getString(R.string.notification_summary_small),
|
||||
wrapItemAt(array, 1),
|
||||
wrapItemAt(array, 0));
|
||||
wrapItemAt(notifications.get(1)),
|
||||
wrapItemAt(notifications.get(0)));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
/* Copyright 2020 Tusky Contributors
|
||||
*
|
||||
* This file is part of Tusky.
|
||||
*
|
||||
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
* Lesser General Public License as published by the Free Software Foundation, either version 3 of
|
||||
* the License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License along with Tusky. If
|
||||
* not, see <http://www.gnu.org/licenses/>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
private val notificationsFetcher: NotificationFetcher
|
||||
) : Worker(context, params) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
notificationsFetcher.fetchAndShow()
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationWorkerFactory @Inject constructor(
|
||||
private val notificationsFetcher: NotificationFetcher
|
||||
) : WorkerFactory() {
|
||||
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters
|
||||
): ListenableWorker? {
|
||||
if (workerClassName == NotificationWorker::class.java.name) {
|
||||
return NotificationWorker(appContext, workerParameters, notificationsFetcher)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,692 @@
|
|||
/*
|
||||
* 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.chinwag_green)
|
||||
|
||||
// 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 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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.ViewGroup
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||