Merge tag 'v28.0' into develop

# Conflicts:
#	README.md
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
#	app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt
#	app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
#	app/src/main/res/color/compound_button_color.xml
#	app/src/main/res/color/text_input_layout_box_stroke_color.xml
#	app/src/main/res/drawable/ic_check_circle.xml
#	app/src/main/res/drawable/ic_flag_24dp.xml
#	app/src/main/res/drawable/ic_person_add_24dp.xml
#	app/src/main/res/drawable/ic_play_indicator.xml
#	app/src/main/res/drawable/ic_poll_24dp.xml
#	app/src/main/res/drawable/ic_reblog_active_24dp.xml
#	app/src/main/res/drawable/ic_reblog_private_active_24dp.xml
#	app/src/main/res/drawable/report_success_background.xml
#	app/src/main/res/layout-land/item_trending_cell.xml
#	app/src/main/res/layout/activity_account.xml
#	app/src/main/res/layout/activity_edit_filter.xml
#	app/src/main/res/layout/card_license.xml
#	app/src/main/res/layout/item_announcement.xml
#	app/src/main/res/layout/item_status.xml
#	app/src/main/res/layout/item_status_detailed.xml
#	app/src/main/res/layout/item_tab_preference.xml
#	app/src/main/res/layout/item_trending_cell.xml
#	app/src/main/res/values-cs/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-eu/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-kab/strings.xml
#	app/src/main/res/values-lv/strings.xml
#	app/src/main/res/values-nb-rNO/strings.xml
#	app/src/main/res/values-night/theme_colors.xml
#	app/src/main/res/values/colors.xml
#	app/src/main/res/values/strings.xml
#	app/src/main/res/values/styles.xml
#	app/src/main/res/values/theme_colors.xml
This commit is contained in:
Mike Barnes 2026-01-03 09:57:39 +11:00
commit a66f7bb515
614 changed files with 52429 additions and 19916 deletions

View file

@ -1,8 +1,5 @@
package com.keylesspalace.tusky
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
@ -12,20 +9,24 @@ import android.text.method.LinkMovementMethod
import android.text.style.URLSpan
import android.text.util.Linkify
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityAboutBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.NoUnderlineURLSpan
import com.keylesspalace.tusky.util.copyToClipboard
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
class AboutActivity : BottomSheetActivity(), Injectable {
@AndroidEntryPoint
class AboutActivity : BottomSheetActivity() {
@Inject
lateinit var instanceInfoRepository: InstanceInfoRepository
@ -43,6 +44,13 @@ class AboutActivity : BottomSheetActivity(), Injectable {
setTitle(R.string.about_title_activity)
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets ->
val systemBarInsets = insets.getInsets(systemBars())
scrollView.updatePadding(bottom = systemBarInsets.bottom)
insets.inset(0, 0, 0, systemBarInsets.bottom)
}
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
binding.deviceInfo.text = getString(
@ -90,13 +98,11 @@ class AboutActivity : BottomSheetActivity(), Injectable {
}
binding.copyDeviceInfo.setOnClickListener {
val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}"
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Tusky version information", text)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show()
}
copyToClipboard(
"${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}",
getString(R.string.about_copied),
"Tusky version information",
)
}
}
}

View file

@ -16,6 +16,7 @@
package com.keylesspalace.tusky
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -25,52 +26,48 @@ import androidx.appcompat.widget.SearchView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
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.util.visible
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
class AccountsInListFragment : DialogFragment(), Injectable {
@AndroidEntryPoint
class AccountsInListFragment : DialogFragment() {
@Inject
lateinit var viewModelFactory: ViewModelFactory
lateinit var preferences: SharedPreferences
private val viewModel: AccountsInListViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentAccountsInListBinding::bind)
private val viewModel: AccountsInListViewModel by viewModels()
private lateinit var binding: FragmentAccountsInListBinding
private lateinit var listId: String
private lateinit var listName: String
private val adapter = Adapter()
private val searchAdapter = SearchAdapter()
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) }
private val animateAvatar by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
private val animateEmojis by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
private val showBotOverlay by unsafeLazy { preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
val args = requireArguments()
listId = args.getString(LIST_ID_ARG)!!
listName = args.getString(LIST_NAME_ARG)!!
@ -78,6 +75,8 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.load(listId)
}
override fun getTheme() = R.style.TuskyDialogFragment
override fun onStart() {
super.onStart()
dialog?.apply {
@ -89,31 +88,27 @@ class AccountsInListFragment : DialogFragment(), Injectable {
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_accounts_in_list, container, false)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentAccountsInListBinding.inflate(layoutInflater)
val adapter = Adapter()
val searchAdapter = SearchAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.accountsRecycler.layoutManager = LinearLayoutManager(view.context)
binding.accountsRecycler.layoutManager = LinearLayoutManager(binding.root.context)
binding.accountsRecycler.adapter = adapter
binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context)
binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(binding.root.context)
binding.accountsSearchRecycler.adapter = searchAdapter
viewLifecycleOwner.lifecycleScope.launch {
lifecycleScope.launch {
viewModel.state.collect { state ->
adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
adapter.submitList(state.accounts.getOrDefault(emptyList()))
when (state.accounts) {
is Either.Right -> binding.messageView.hide()
is Either.Left -> handleError(state.accounts.value)
}
state.accounts.fold(
onSuccess = { binding.messageView.hide() },
onFailure = { handleError(it) }
)
setupSearchView(state)
setupSearchView(searchAdapter, state)
}
}
@ -132,15 +127,16 @@ class AccountsInListFragment : DialogFragment(), Injectable {
return true
}
})
return binding.root
}
private fun setupSearchView(state: State) {
private fun setupSearchView(searchAdapter: SearchAdapter, state: State) {
if (state.searchResult == null) {
searchAdapter.submitList(listOf())
binding.accountsSearchRecycler.hide()
binding.accountsRecycler.show()
} else {
val listAccounts = state.accounts.asRightOrNull() ?: listOf()
val listAccounts = state.accounts.getOrDefault(emptyList())
val newList = state.searchResult.map { acc ->
acc to listAccounts.contains(acc)
}
@ -212,6 +208,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
val account = getItem(position)
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
holder.binding.usernameTextView.text = account.username
holder.binding.avatarBadge.visible(showBotOverlay && account.bot)
loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar)
}
}
@ -263,6 +260,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
holder.binding.usernameTextView.text = account.username
holder.binding.avatarBadge.visible(showBotOverlay && account.bot)
loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar)
holder.binding.rejectButton.apply {

View file

@ -1,4 +1,4 @@
/* Copyright 2017 Andrew Dawson
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -12,117 +12,167 @@
*
* 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;
import android.app.ActivityManager.TaskDescription
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.ViewModelProvider.Factory
import androidx.lifecycle.lifecycleScope
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.login.LoginActivity.Companion.getIntent
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.di.PreferencesEntryPoint
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ActivityConstants
import com.keylesspalace.tusky.util.isBlack
import com.keylesspalace.tusky.util.overrideActivityTransitionCompat
import dagger.hilt.EntryPoints
import javax.inject.Inject
import kotlinx.coroutines.launch
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;
import android.os.Bundle;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
import com.keylesspalace.tusky.interfaces.PermissionRequester;
import com.keylesspalace.tusky.settings.AppTheme;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.ActivityExtensions;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import javax.inject.Inject;
import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME;
import static com.keylesspalace.tusky.util.ActivityExtensions.supportsOverridingActivityTransitions;
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN";
private static final String TAG = "BaseActivity";
/**
* All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint
*/
abstract class BaseActivity : AppCompatActivity() {
@Inject
lateinit var accountManager: AccountManager
@Inject
@NonNull
public AccountManager accountManager;
lateinit var preferences: SharedPreferences
private static final int REQUESTER_NONE = Integer.MAX_VALUE;
private HashMap<Integer, PermissionRequester> requesters;
/**
* Allows overriding the default ViewModelProvider.Factory for testing purposes.
*/
var viewModelProviderFactory: Factory? = null
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (supportsOverridingActivityTransitions() && activityTransitionWasRequested()) {
overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.activity_open_enter, R.anim.activity_open_exit);
overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, R.anim.activity_close_enter, R.anim.activity_close_exit);
if (activityTransitionWasRequested()) {
overrideActivityTransitionCompat(
ActivityConstants.OVERRIDE_TRANSITION_OPEN,
R.anim.activity_open_enter,
R.anim.activity_open_exit
)
overrideActivityTransitionCompat(
ActivityConstants.OVERRIDE_TRANSITION_CLOSE,
R.anim.activity_close_enter,
R.anim.activity_close_exit
)
}
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
/* There isn't presently a way to globally change the theme of a whole application at
* runtime, just individual activities. So, each activity has to set its theme before any
* views are created. */
String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue());
Log.d("activeTheme", theme);
if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) {
setTheme(R.style.TuskyBlackTheme);
val theme = preferences.getString(PrefKeys.APP_THEME, AppTheme.DEFAULT.value)
if (isBlack(resources.configuration, theme)) {
setTheme(R.style.TuskyBlackTheme)
} else if (this is MainActivity) {
// Replace the SplashTheme of MainActivity
setTheme(R.style.TuskyTheme)
}
/* 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, com.google.android.material.R.attr.colorSurface, Color.BLACK);
/* Set the taskdescription programmatically - by default the primary color is used.
* On newer Android versions (or launchers?) this doesn't seem to have an effect. */
val appName = getString(R.string.app_name)
val recentsBackgroundColor = MaterialColors.getColor(
this,
materialR.attr.colorSurface,
Color.BLACK
)
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"));
getTheme().applyStyle(style, true);
if(requiresLogin()) {
redirectIfNotLoggedIn();
val taskDescription = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
TaskDescription.Builder()
.setLabel(appName)
.setIcon(R.mipmap.ic_launcher)
.setPrimaryColor(recentsBackgroundColor)
.build()
} else {
val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
@Suppress("DEPRECATION")
TaskDescription(appName, appIcon, recentsBackgroundColor)
}
requesters = new HashMap<>();
setTaskDescription(taskDescription)
val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"))
getTheme().applyStyle(style, true)
if (requiresLogin()) {
redirectIfNotLoggedIn()
}
}
private boolean activityTransitionWasRequested() {
return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false);
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
// currently only ComposeActivity on tablets is floating
if (!window.isFloating) {
window.decorView.setBackgroundColor(Color.BLACK)
val contentView: View = findViewById(android.R.id.content)
contentView.setBackgroundColor(MaterialColors.getColor(contentView, android.R.attr.colorBackground))
// handle left/right insets. This is relevant for edge-to-edge mode in landscape orientation
ViewCompat.setOnApplyWindowInsetsListener(contentView) { _, insets ->
val systemBarInsets = insets.getInsets(systemBars())
val displayCutoutInsets = insets.getInsets(displayCutout())
// use padding for system bar insets so they get our background color and margin for cutout insets to turn them black
contentView.updatePadding(left = systemBarInsets.left, right = systemBarInsets.right)
contentView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = displayCutoutInsets.left
rightMargin = displayCutoutInsets.right
}
WindowInsetsCompat.Builder(insets)
.setInsets(systemBars(), Insets.of(0, systemBarInsets.top, 0, systemBarInsets.bottom))
.setInsets(displayCutout(), Insets.of(0, displayCutoutInsets.top, 0, displayCutoutInsets.bottom))
.build()
}
}
}
@Override
protected void attachBaseContext(Context newBase) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
private fun activityTransitionWasRequested(): Boolean {
return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false)
}
override fun attachBaseContext(newBase: Context) {
// injected preferences not yet available at this point of the lifecycle
val preferences =
EntryPoints.get(newBase.applicationContext, PreferencesEntryPoint::class.java)
.preferences()
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f)
Configuration configuration = newBase.getResources().getConfiguration();
val configuration = newBase.resources.configuration
// Adjust `fontScale` in the configuration.
//
@ -134,7 +184,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
// 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();
val appConfiguration = newBase.applicationContext.resources.configuration
// 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
@ -146,163 +196,116 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
//
// val displayMetrics = appContext.resources.displayMetrics
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f
Context fontScaleContext = newBase.createConfigurationContext(configuration);
val fontScaleContext = newBase.createConfigurationContext(configuration)
super.attachBaseContext(fontScaleContext);
super.attachBaseContext(fontScaleContext)
}
protected boolean requiresLogin() {
return true;
}
override val defaultViewModelProviderFactory: Factory
get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory
private static int textStyle(String name) {
int style;
switch (name) {
case "smallest":
style = R.style.TextSizeSmallest;
break;
case "small":
style = R.style.TextSizeSmall;
break;
case "medium":
default:
style = R.style.TextSizeMedium;
break;
case "large":
style = R.style.TextSizeLarge;
break;
case "largest":
style = R.style.TextSizeLargest;
break;
protected open fun requiresLogin(): Boolean = true
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressedDispatcher.onBackPressed()
return true
}
return style;
return super.onOptionsItemSelected(item)
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
getOnBackPressedDispatcher().onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
private fun redirectIfNotLoggedIn() {
val currentAccounts = accountManager.accounts
@Override
public void finish() {
super.finish();
// if this activity was opened with slide-in, close it with slide out
if (!supportsOverridingActivityTransitions() && activityTransitionWasRequested()) {
overridePendingTransition(R.anim.activity_close_enter, R.anim.activity_close_exit);
if (currentAccounts.isEmpty()) {
val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
}
protected void redirectIfNotLoggedIn() {
AccountEntity account = accountManager.getActiveAccount();
if (account == null) {
Intent intent = new Intent(this, LoginActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
ActivityExtensions.startActivityWithSlideInAnimation(this, intent);
finish();
}
}
fun showAccountChooserDialog(
dialogTitle: CharSequence?,
showActiveAccount: Boolean,
listener: AccountSelectionListener
) {
val accounts = accountManager.accounts.toMutableList()
val activeAccount = accountManager.activeAccount
protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) {
if (anyView != null) {
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);
bar.setAction(actionId, listener);
bar.show();
}
}
public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
AccountEntity activeAccount = accountManager.getActiveAccount();
switch(accounts.size()) {
case 1:
listener.onAccountSelected(activeAccount);
return;
case 2:
if (!showActiveAccount) {
for (AccountEntity account : accounts) {
if (activeAccount != account) {
listener.onAccountSelected(account);
return;
}
when (accounts.size) {
1 -> {
listener.onAccountSelected(activeAccount!!)
return
}
2 -> if (!showActiveAccount) {
for (account in accounts) {
if (activeAccount !== account) {
listener.onAccountSelected(account)
return
}
}
break;
}
if (!showActiveAccount && activeAccount != null) {
accounts.remove(activeAccount);
}
AccountSelectionAdapter adapter = new AccountSelectionAdapter(this);
adapter.addAll(accounts);
new AlertDialog.Builder(this)
.setTitle(dialogTitle)
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
.show();
}
public @Nullable String getOpenAsText() {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
switch (accounts.size()) {
case 0:
case 1:
return null;
case 2:
for (AccountEntity account : accounts) {
if (account != accountManager.getActiveAccount()) {
return String.format(getString(R.string.action_open_as), account.getFullName());
}
}
return null;
default:
return String.format(getString(R.string.action_open_as), "");
}
}
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account.getId());
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
startActivity(intent);
finish();
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requesters.containsKey(requestCode)) {
PermissionRequester requester = requesters.remove(requestCode);
requester.onRequestPermissionsResult(permissions, grantResults);
}
}
public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) {
ArrayList<String> permissionsToRequest = new ArrayList<>();
for(String permission: permissions) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
permissionsToRequest.add(permission);
}
}
if (permissionsToRequest.isEmpty()) {
int[] permissionsAlreadyGranted = new int[permissions.length];
requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted);
return;
if (!showActiveAccount && activeAccount != null) {
accounts.remove(activeAccount)
}
val adapter = AccountSelectionAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
adapter.addAll(accounts)
MaterialAlertDialogBuilder(this)
.setTitle(dialogTitle)
.setAdapter(adapter) { _: DialogInterface?, index: Int ->
listener.onAccountSelected(accounts[index])
}
.show()
}
val openAsText: String?
get() {
val accounts = accountManager.accounts
when (accounts.size) {
0, 1 -> return null
2 -> {
for (account in accounts) {
if (account !== accountManager.activeAccount) {
return getString(R.string.action_open_as, account.fullName)
}
}
return null
}
else -> return getString(R.string.action_open_as, "")
}
}
int newKey = requester == null ? REQUESTER_NONE : requesters.size();
if (newKey != REQUESTER_NONE) {
requesters.put(newKey, requester);
}
String[] permissionsCopy = new String[permissionsToRequest.size()];
permissionsToRequest.toArray(permissionsCopy);
ActivityCompat.requestPermissions(this, permissionsCopy, newKey);
fun openAsAccount(url: String, account: AccountEntity) {
lifecycleScope.launch {
accountManager.setActiveAccount(account.id)
val intent = redirectIntent(this@BaseActivity, account.id, url)
startActivity(intent)
finish()
}
}
companion object {
const val OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"
@StyleRes
private fun textStyle(name: String?): Int = when (name) {
"smallest" -> R.style.TextSizeSmallest
"small" -> R.style.TextSizeSmall
"medium" -> R.style.TextSizeMedium
"large" -> R.style.TextSizeLarge
"largest" -> R.style.TextSizeLargest
else -> R.style.TextSizeMedium
}
}
}

View file

@ -22,6 +22,10 @@ import android.view.View
import android.widget.LinearLayout
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -62,6 +66,14 @@ abstract class BottomSheetActivity : BaseActivity() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})
ViewCompat.setOnApplyWindowInsetsListener(bottomSheetLayout) { _, insets ->
val systemBarsInsets = insets.getInsets(systemBars() or ime())
val bottomInsets = systemBarsInsets.bottom
bottomSheetLayout.updatePadding(bottom = bottomInsets)
insets
}
}
open fun viewUrl(

View file

@ -17,7 +17,6 @@ package com.keylesspalace.tusky
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Log
@ -28,7 +27,13 @@ import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@ -39,12 +44,13 @@ 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.R as materialR
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
@ -57,11 +63,12 @@ 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
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class EditProfileActivity : BaseActivity(), Injectable {
@AndroidEntryPoint
class EditProfileActivity : BaseActivity() {
companion object {
const val AVATAR_SIZE = 400
@ -69,10 +76,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
const val HEADER_HEIGHT = 500
}
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: EditProfileViewModel by viewModels { viewModelFactory }
private val viewModel: EditProfileViewModel by viewModels()
private val binding by viewBinding(ActivityEditProfileBinding::inflate)
@ -121,6 +125,25 @@ class EditProfileActivity : BaseActivity(), Injectable {
setDisplayShowHomeEnabled(true)
}
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { scrollView, insets ->
// if keyboard visible -> set inset on the root to push the scrollview up
// if keyboard hidden -> set inset on the scrollview so last element does not get obscured by navigation bar
// scrollview has clipToPadding set to false so it draws behind the navigation bar in edge-to-edge mode
val imeInsets = insets.getInsets(ime())
val systemBarsInsets = insets.getInsets(systemBars())
binding.root.updatePadding(bottom = imeInsets.bottom)
val scrollViewPadding = if (imeInsets.bottom == 0) {
systemBarsInsets.bottom
} else {
0
}
binding.scrollView.updatePadding(bottom = scrollViewPadding)
WindowInsetsCompat.Builder(insets)
.setInsets(ime(), Insets.of(imeInsets.left, imeInsets.top, imeInsets.right, 0))
.setInsets(systemBars(), Insets.of(systemBarsInsets.left, systemBarsInsets.top, imeInsets.right, 0))
.build()
}
binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) }
binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) }
@ -129,7 +152,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply {
sizeDp = 12
colorInt = Color.WHITE
colorInt = MaterialColors.getColor(binding.addFieldButton, materialR.attr.colorOnPrimary)
}
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
@ -369,7 +392,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
private suspend fun launchSaveDialog() = AlertDialog.Builder(this)
private suspend fun launchSaveDialog() = MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_save_profile_changes_message))
.create()
.await(R.string.action_save, R.string.action_discard)

View file

@ -19,8 +19,12 @@ import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.annotation.RawRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -28,6 +32,7 @@ import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
@AndroidEntryPoint
class LicenseActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -43,6 +48,13 @@ class LicenseActivity : BaseActivity() {
setTitle(R.string.title_licenses)
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets ->
val systemBarInsets = insets.getInsets(systemBars())
scrollView.updatePadding(bottom = systemBarInsets.bottom)
insets.inset(0, 0, 0, systemBarInsets.bottom)
}
loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView)
}

View file

@ -23,24 +23,25 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.PopupMenu
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
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.databinding.DialogListBinding
import com.keylesspalace.tusky.databinding.ItemListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.ensureBottomMargin
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
@ -53,22 +54,15 @@ import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
// TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?)
class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
@AndroidEntryPoint
class ListsActivity : BaseActivity() {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
private val viewModel: ListsViewModel by viewModels { viewModelFactory }
private val viewModel: ListsViewModel by viewModels()
private val binding by viewBinding(ActivityListsBinding::inflate)
@ -86,6 +80,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
setDisplayShowHomeEnabled(true)
}
binding.addListButton.ensureBottomMargin()
binding.listsRecycler.ensureBottomPadding(fab = true)
binding.listsRecycler.adapter = adapter
binding.listsRecycler.layoutManager = LinearLayoutManager(this)
binding.listsRecycler.addItemDecoration(
@ -93,7 +90,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
)
binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
lifecycleScope.launch {
viewModel.state.collect(this@ListsActivity::update)
@ -117,11 +113,23 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
private fun showlistNameDialog(list: MastoList?) {
var selectedReplyPolicyIndex = MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal
val replyPolicies = resources.getStringArray(R.array.list_reply_policies_display)
val binding = DialogListBinding.inflate(layoutInflater).apply {
replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal)
replyPolicyDropDown.setText(replyPolicies[selectedReplyPolicyIndex])
replyPolicyDropDown.setSimpleItems(replyPolicies)
replyPolicyDropDown.setOnItemClickListener { _, _, position, _ ->
selectedReplyPolicyIndex = position
}
}
val dialog = AlertDialog.Builder(this)
val inset = resources.getDimensionPixelSize(R.dimen.dialog_inset)
val dialog = MaterialAlertDialogBuilder(this)
.setView(binding.root)
.setBackgroundInsetTop(inset)
.setBackgroundInsetEnd(inset)
.setBackgroundInsetBottom(inset)
.setBackgroundInsetStart(inset)
.setPositiveButton(
if (list == null) {
R.string.action_create_list
@ -133,17 +141,23 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
binding.nameText.text.toString(),
list?.id,
binding.exclusiveCheckbox.isChecked,
MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy
MastoList.ReplyPolicy.entries[selectedReplyPolicyIndex].policy
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
// yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard
dialog.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
binding.nameText.let { editText ->
editText.doOnTextChanged { s, _, _, _ ->
dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true
}
editText.setText(list?.title)
editText.requestFocus()
editText.text?.let { editText.setSelection(it.length) }
}
@ -157,7 +171,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
private fun showListDeleteDialog(list: MastoList) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_delete_list_warning, list.title))
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteList(list.id)
@ -287,8 +301,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
fun newIntent(context: Context) = Intent(context, ListsActivity::class.java)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
/* Copyright 2024 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
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ShareShortcutHelper
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class MainViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val api: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager,
private val shareShortcutHelper: ShareShortcutHelper,
private val notificationService: NotificationService,
) : ViewModel() {
private val activeAccount = accountManager.activeAccount!!
val accounts: StateFlow<List<AccountViewData>> = accountManager.accountsFlow
.map { accounts ->
accounts.map { account ->
AccountViewData(
id = account.id,
domain = account.domain,
username = account.username,
displayName = account.displayName,
profilePictureUrl = account.profilePictureUrl,
profileHeaderUrl = account.profileHeaderUrl,
emojis = account.emojis
)
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val tabs: StateFlow<List<TabData>> = accountManager.activeAccount(viewModelScope)
.mapNotNull { account -> account?.tabPreferences }
.stateIn(viewModelScope, SharingStarted.Eagerly, activeAccount.tabPreferences)
private val _unreadAnnouncementsCount = MutableStateFlow(0)
val unreadAnnouncementsCount: StateFlow<Int> = _unreadAnnouncementsCount.asStateFlow()
val showDirectMessagesBadge: StateFlow<Boolean> = accountManager.activeAccount(viewModelScope)
.map { account -> account?.hasDirectMessageBadge == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
init {
loadAccountData()
fetchAnnouncements()
collectEvents()
}
private fun loadAccountData() {
viewModelScope.launch {
api.accountVerifyCredentials().fold(
{ userInfo ->
accountManager.updateAccount(activeAccount, userInfo)
shareShortcutHelper.updateShortcuts()
setupNotifications(activeAccount)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info.", throwable)
}
)
}
}
private fun fetchAnnouncements() {
viewModelScope.launch {
api.announcements()
.fold(
{ announcements ->
_unreadAnnouncementsCount.value = announcements.count { !it.read }
},
{ throwable ->
Log.w(TAG, "Failed to fetch announcements.", throwable)
}
)
}
}
private fun collectEvents() {
viewModelScope.launch {
eventHub.events.collect { event ->
when (event) {
is AnnouncementReadEvent -> {
_unreadAnnouncementsCount.value--
}
is NewNotificationsEvent -> {
if (event.accountId == activeAccount.accountId) {
val hasDirectMessageNotification =
event.notifications.any {
it.type == Notification.Type.Mention && it.status?.visibility == Status.Visibility.DIRECT
}
if (hasDirectMessageNotification) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) }
}
}
}
is NotificationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
is ConversationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
}
}
}
}
fun dismissDirectMessagesBadge() {
viewModelScope.launch {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
fun setupNotifications(account: AccountEntity? = null) {
// TODO this is only called on full app (re) start; so changes in-between (push distributor uninstalled/subscription changed, or
// notifications fully disabled) will get unnoticed; and also an app restart cannot be easily triggered by the user.
if (account != null) {
// TODO it's quite odd to separate channel creation (for an account) from the "is enabled by channels" question below
notificationService.createNotificationChannelsForAccount(account)
}
if (notificationService.areNotificationsEnabledBySystem()) {
viewModelScope.launch {
notificationService.setupNotifications(account)
}
} else {
viewModelScope.launch {
notificationService.disableAllNotifications()
}
}
}
companion object {
private const val TAG = "MainViewModel"
}
}
data class AccountViewData(
val id: Long,
val domain: String,
val username: String,
val displayName: String,
val profilePictureUrl: String,
val profileHeaderUrl: String,
val emojis: List<Emoji>
) {
val fullName: String
get() = "@$username@$domain"
}

View file

@ -1,46 +0,0 @@
/* Copyright 2018 Conny Duck
*
* 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
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import javax.inject.Inject
@SuppressLint("CustomSplashScreen")
class SplashActivity : AppCompatActivity(), Injectable {
@Inject
lateinit var accountManager: AccountManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/** Determine whether the user is currently logged in, and if so go ahead and load the
* timeline. Otherwise, start the activity_login screen. */
val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java)
} else {
LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT)
}
startActivity(intent)
finish()
}
}

View file

@ -26,8 +26,9 @@ import androidx.lifecycle.lifecycleScope
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.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.components.filters.EditFilterActivity
import com.keylesspalace.tusky.components.filters.FilterExpiration
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
@ -37,15 +38,12 @@ import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@AndroidEntryPoint
class StatusListActivity : BottomSheetActivity() {
@Inject
lateinit var eventHub: EventHub
@ -78,7 +76,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
val title = when (kind) {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
Kind.TAG -> getString(R.string.hashtag_format, hashtag)
Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
}
@ -215,12 +213,12 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
hashedTag == filter.phrase && filter.context.contains(Filter.Kind.HOME.kind)
}
updateTagMuteState(mutedFilterV1 != null)
},
{ throwable ->
Log.e(TAG, "Error getting filters: $throwable")
{ throwable2 ->
Log.e(TAG, "Error getting filters: $throwable2")
}
)
} else {
@ -252,9 +250,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.createFilter(
title = "#$tag",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
filterAction = Filter.Action.WARN.action,
expiresInSeconds = null
expiresIn = FilterExpiration.never
).fold(
{ filter ->
if (mastodonApi.addFilterKeyword(
@ -266,8 +264,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
// must be requested again; otherwise does not contain the keyword (but server does)
mutedFilter = mastodonApi.getFilter(filter.id).getOrNull()
// TODO the preference key here ("home") is not meaningful; should probably be another event if any
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
eventHub.dispatch(FilterUpdatedEvent(filter.context))
filterCreateSuccess = true
} else {
Snackbar.make(
@ -282,23 +279,23 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
if (throwable.isHttpNotFound()) {
mastodonApi.createFilterV1(
hashedTag,
listOf(FilterV1.HOME),
listOf(Filter.Kind.HOME.kind),
irreversible = false,
wholeWord = true,
expiresInSeconds = null
expiresIn = FilterExpiration.never
).fold(
{ filter ->
mutedFilterV1 = filter
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
eventHub.dispatch(FilterUpdatedEvent(filter.context))
filterCreateSuccess = true
},
{ throwable ->
{ throwable2 ->
Snackbar.make(
binding.root,
getString(R.string.error_muting_hashtag_format, tag),
Snackbar.LENGTH_SHORT
).show()
Log.e(TAG, "Failed to mute #$tag", throwable)
Log.e(TAG, "Failed to mute #$tag", throwable2)
}
)
} else {
@ -359,10 +356,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
mastodonApi.updateFilterV1(
id = filter.id,
phrase = filter.phrase,
context = filter.context.filter { it != FilterV1.HOME },
context = filter.context.filter { it != Filter.Kind.HOME.kind },
irreversible = null,
wholeWord = null,
expiresInSeconds = null
expiresIn = FilterExpiration.never
)
} else {
mastodonApi.deleteFilterV1(filter.id)
@ -375,7 +372,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
result?.fold(
{
updateTagMuteState(false)
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME.kind)))
mutedFilterV1 = null
mutedFilter = null
@ -399,8 +396,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
return true
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val EXTRA_KIND = "kind"

View file

@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import java.util.Objects
/** this would be a good case for a sealed class, but that does not work nice with Room */
@ -60,7 +60,7 @@ data class TabData(
override fun hashCode() = Objects.hash(id, arguments)
}
fun List<TabData>.hasTab(id: String): Boolean = this.find { it.id == id } != null
fun List<TabData>.hasTab(id: String): Boolean = this.any { it.id == id }
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
return when (id) {
@ -118,7 +118,7 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
arguments = arguments,
title = { context ->
arguments.joinToString(separator = " ") {
context.getString(R.string.title_tag, it)
context.getString(R.string.hashtag_format, it)
}
}
)

View file

@ -18,41 +18,37 @@ package com.keylesspalace.tusky
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.updatePadding
import androidx.core.widget.doOnTextChanged
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import at.connyduck.sparkbutton.helpers.Utils
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.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
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.ensureBottomPadding
import com.keylesspalace.tusky.util.unsafeLazy
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.util.regex.Pattern
import com.keylesspalace.tusky.view.showHashtagPickerDialog
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, ItemInteractionListener, ListSelectionFragment.ListSelectionListener {
@AndroidEntryPoint
class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelectionFragment.ListSelectionListener {
@Inject
lateinit var mastodonApi: MastodonApi
@ -60,9 +56,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
private val binding by viewBinding(ActivityTabPreferenceBinding::inflate)
private lateinit var currentTabs: MutableList<TabData>
@ -70,16 +63,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
private lateinit var touchHelper: ItemTouchHelper
private lateinit var addTabAdapter: TabAdapter
private var tabsChanged = false
private val selectedItemElevation by unsafeLazy {
resources.getDimension(R.dimen.selected_drag_item_elevation)
}
private val hashtagRegex by unsafeLazy {
Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE)
}
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
toggleFab(false)
@ -99,6 +86,19 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
setDisplayShowHomeEnabled(true)
}
binding.currentTabsRecyclerView.ensureBottomPadding(fab = true)
ViewCompat.setOnApplyWindowInsetsListener(binding.actionButton) { _, insets ->
val bottomInset = insets.getInsets(systemBars()).bottom
val actionButtonMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
binding.actionButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = bottomInset + actionButtonMargin
}
binding.sheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = bottomInset + actionButtonMargin
}
insets.inset(0, 0, 0, bottomInset)
}
currentTabs = accountManager.activeAccount?.tabPreferences.orEmpty().toMutableList()
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
binding.currentTabsRecyclerView.adapter = currentTabsAdapter
@ -233,44 +233,21 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
}
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)
showHashtagPickerDialog(mastodonApi, R.string.add_hashtag_title) { hashtag ->
if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(hashtag))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + hashtag)
currentTabs[tabPosition] = newTab
val editText = AppCompatEditText(this)
editText.setHint(R.string.edit_hashtag_hint)
editText.setText("")
frameLayout.addView(editText)
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.add_hashtag_title)
.setView(frameLayout)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_save) { _, _ ->
val input = editText.text.toString().trim()
if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(input))
currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + input)
currentTabs[tabPosition] = newTab
currentTabsAdapter.notifyItemChanged(tabPosition)
}
updateAvailableTabs()
saveTabs()
currentTabsAdapter.notifyItemChanged(tabPosition)
}
.create()
editText.doOnTextChanged { s, _, _, _ ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
updateAvailableTabs()
saveTabs()
}
dialog.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(editText.text)
editText.requestFocus()
}
private var listSelectDialog: ListSelectionFragment? = null
@ -293,11 +270,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
saveTabs()
}
private fun validateHashtag(input: CharSequence?): Boolean {
val trimmedInput = input?.trim() ?: ""
return trimmedInput.isNotEmpty() && hashtagRegex.matcher(trimmedInput).matches()
}
private fun updateAvailableTabs() {
val addableTabs: MutableList<TabData> = mutableListOf()
@ -351,25 +323,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, It
private fun saveTabs() {
accountManager.activeAccount?.let {
lifecycleScope.launch(Dispatchers.IO) {
it.tabPreferences = currentTabs
accountManager.saveAccount(it)
}
}
tabsChanged = true
}
override fun onPause() {
super.onPause()
if (tabsChanged) {
lifecycleScope.launch {
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
accountManager.updateAccount(it) { copy(tabPreferences = currentTabs) }
}
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val MIN_TAB_COUNT = 2
}

View file

@ -16,14 +16,16 @@
package com.keylesspalace.tusky
import android.app.Application
import android.app.NotificationManager
import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
import com.keylesspalace.tusky.settings.PrefKeys
@ -32,9 +34,7 @@ import com.keylesspalace.tusky.settings.SCHEMA_VERSION
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 dagger.hilt.android.HiltAndroidApp
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
import de.c1710.filemojicompat_ui.helpers.EmojiPreference
@ -43,18 +43,20 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
import org.conscrypt.Conscrypt
class TuskyApplication : Application(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@HiltAndroidApp
class TuskyApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: WorkerFactory
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var localeManager: LocaleManager
@Inject
lateinit var sharedPreferences: SharedPreferences
lateinit var preferences: SharedPreferences
@Inject
lateinit var notificationManager: NotificationManager
override fun onCreate() {
// Uncomment me to get StrictMode violation logs
@ -71,14 +73,27 @@ class TuskyApplication : Application(), HasAndroidInjector {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
AppInjector.init(this)
val workManager = WorkManager.getInstance(this)
// Migrate shared preference keys and defaults from version to version.
val oldVersion = sharedPreferences.getInt(
val oldVersion = preferences.getInt(
PrefKeys.SCHEMA_VERSION,
NEW_INSTALL_SCHEMA_VERSION
)
if (oldVersion != SCHEMA_VERSION) {
if (oldVersion < 2025021701) {
// A new periodic work request is enqueued by unique name (and not tag anymore): stop the old one
workManager.cancelAllWorkByTag("pullNotifications")
}
if (oldVersion < 2025022001 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// delete old now unused notification channels
for (channel in notificationManager.notificationChannels) {
if (channel.id.startsWith("CHANNEL_SIGN_UP") || channel.id.startsWith("CHANNEL_REPORT")) {
notificationManager.deleteNotificationChannel(channel.id)
}
}
}
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
}
@ -88,36 +103,30 @@ class TuskyApplication : Application(), HasAndroidInjector {
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
// init night mode
val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value)
val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value)
setAppNightMode(theme)
localeManager.setLocale()
NotificationHelper.createWorkerNotificationChannel(this)
WorkManager.initialize(
this,
androidx.work.Configuration.Builder()
.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(
workManager.enqueueUniquePeriodicWork(
PruneCacheWorker.PERIODIC_WORK_TAG,
ExistingPeriodicWorkPolicy.KEEP,
pruneCacheWorker
)
}
override fun androidInjector() = androidInjector
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) {
Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion")
val editor = sharedPreferences.edit()
val editor = preferences.edit()
if (oldVersion < 2023022701) {
// These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity.
@ -127,17 +136,11 @@ class TuskyApplication : Application(), HasAndroidInjector {
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
}
if (oldVersion < 2023072401) {
// The notifications filter / clear options are shown on a menu, not a separate bar,
// the preference to display them is not needed.
editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER)
}
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
// didn't have an explicit preference set use the previous default, so the
// theme does not unexpectedly change.
if (!sharedPreferences.contains(APP_THEME)) {
if (!preferences.contains(APP_THEME)) {
editor.putString(APP_THEME, AppTheme.NIGHT.value)
}
}
@ -148,6 +151,10 @@ class TuskyApplication : Application(), HasAndroidInjector {
editor.remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS)
}
if (oldVersion < 2024060201) {
editor.remove(PrefKeys.Deprecated.FAB_HIDE)
}
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
editor.apply()
}

View file

@ -19,11 +19,8 @@ import android.Manifest
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.DownloadManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
@ -38,14 +35,15 @@ import android.view.View
import android.view.WindowManager
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.content.IntentCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
@ -54,32 +52,29 @@ import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.fragment.ViewVideoFragment
import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.copyToClipboard
import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.submitAsync
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
@AndroidEntryPoint
class ViewMediaActivity :
BaseActivity(),
HasAndroidInjector,
ViewImageFragment.PhotoActionsListener,
ViewVideoFragment.VideoActionsListener {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
val toolbar: View
@ -92,6 +87,17 @@ class ViewMediaActivity :
private val toolbarVisibilityListeners = mutableListOf<ToolbarVisibilityListener>()
private var imageUrl: String? = null
private val requestDownloadMediaPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
downloadMedia()
} else {
Snackbar.make(binding.toolbar, getString(R.string.error_media_download_permission), Snackbar.LENGTH_SHORT)
.setAction(R.string.action_retry) { requestDownloadMedia() }
.show()
}
}
fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0<Boolean> {
this.toolbarVisibilityListeners.add(listener)
listener(isToolbarVisible)
@ -105,11 +111,7 @@ class ViewMediaActivity :
supportPostponeEnterTransition()
// Gather the parameters.
attachments = IntentCompat.getParcelableArrayListExtra(
intent,
EXTRA_ATTACHMENTS,
AttachmentViewData::class.java
)
attachments = intent.getParcelableArrayListExtraCompat(EXTRA_ATTACHMENTS)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
@ -154,9 +156,13 @@ class ViewMediaActivity :
true
}
// yes it is deprecated, but it looks cool so it stays for now
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE
window.statusBarColor = Color.BLACK
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@Suppress("DEPRECATION")
window.statusBarColor = Color.BLACK
}
window.sharedElementEnterTransition.addListener(object : NoopTransitionListener {
override fun onTransitionEnd(transition: Transition) {
adapter.onTransitionEnd(binding.viewPager.currentItem)
@ -235,22 +241,7 @@ class ViewMediaActivity :
private fun requestDownloadMedia() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
) { _, grantResults ->
if (
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
downloadMedia()
} else {
showErrorDialog(
binding.toolbar,
R.string.error_media_download_permission,
R.string.action_retry
) { requestDownloadMedia() }
}
}
requestDownloadMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
downloadMedia()
}
@ -264,9 +255,10 @@ class ViewMediaActivity :
}
private fun copyLink() {
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, url))
copyToClipboard(
imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url,
getString(R.string.url_copied),
)
}
private fun shareMedia() {
@ -367,8 +359,6 @@ class ViewMediaActivity :
}
}
override fun androidInjector() = androidInjector
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"

View file

@ -20,15 +20,17 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(
class AccountSelectionAdapter(
context: Context,
private val animateAvatars: Boolean,
private val animateEmojis: Boolean
) : ArrayAdapter<AccountEntity>(
context,
R.layout.item_autocomplete_account
) {
@ -42,17 +44,13 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(
val account = getItem(position)
if (account != null) {
val pm = PreferenceManager.getDefaultSharedPreferences(binding.avatar.context)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
binding.username.text = account.fullName
binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis)
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatars)
}
return binding.root

View file

@ -33,6 +33,7 @@ class EmojiAdapter(
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker }
.sortedBy { it.shortcode.lowercase(Locale.ROOT) }
.sortedBy { it.category?.lowercase(Locale.ROOT) ?: "" }
override fun getItemCount() = emojiList.size

View file

@ -0,0 +1,61 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.NotificationsViewHolder
import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterResult
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
class FilteredStatusViewHolder(
private val binding: ItemStatusFilteredBinding,
listener: StatusActionListener
) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) {
init {
binding.statusFilterShowAnyway.setOnClickListener {
listener.clearWarningAction(bindingAdapterPosition)
}
}
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isEmpty()) {
bind(viewData.statusViewData!!)
}
}
fun bind(viewData: StatusViewData.Concrete) {
val matchedFilterResult: FilterResult? = viewData.actionable.filtered.orEmpty().find { filterResult ->
filterResult.filter.action == Filter.Action.WARN
}
val matchedFilterTitle = matchedFilterResult?.filter?.title.orEmpty()
binding.statusFilterLabel.text = itemView.context.getString(
R.string.status_filter_placeholder_label_format,
matchedFilterTitle
)
}
}

View file

@ -21,10 +21,12 @@ 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.NotificationsViewHolder
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
@ -33,12 +35,31 @@ 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 accountListener: AccountActionListener,
private val linkListener: LinkListener,
private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) {
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isNotEmpty()) {
return
}
setupWithAccount(
viewData.account,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.animateEmojis,
statusDisplayOptions.showBotOverlay
)
setupActionListener(accountListener, viewData.account.id)
}
fun setupWithAccount(
account: TimelineAccount,
@ -98,5 +119,6 @@ class FollowRequestViewHolder(
}
}
itemView.setOnClickListener { listener.onViewAccount(accountId) }
binding.accountNote.setOnClickListener { listener.onViewAccount(accountId) }
}
}

View file

@ -1,708 +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<RecyclerView.ViewHolder> implements LinkListener{
public interface AdapterDataSource<T> {
int getItemCount();
T getItemAt(int pos);
}
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
private static final int VIEW_TYPE_FOLLOW = 2;
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
private static final int VIEW_TYPE_PLACEHOLDER = 4;
private static final int VIEW_TYPE_REPORT = 5;
private static final int VIEW_TYPE_UNKNOWN = 6;
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private final String accountId;
private StatusDisplayOptions statusDisplayOptions;
private final StatusActionListener statusListener;
private final NotificationActionListener notificationActionListener;
private final AccountActionListener accountActionListener;
private final AdapterDataSource<NotificationViewData> dataSource;
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
public NotificationsAdapter(String accountId,
AdapterDataSource<NotificationViewData> dataSource,
StatusDisplayOptions statusDisplayOptions,
StatusActionListener statusListener,
NotificationActionListener notificationActionListener,
AccountActionListener accountActionListener) {
this.accountId = accountId;
this.dataSource = dataSource;
this.statusDisplayOptions = statusDisplayOptions;
this.statusListener = statusListener;
this.notificationActionListener = notificationActionListener;
this.accountActionListener = accountActionListener;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
switch (viewType) {
case VIEW_TYPE_STATUS: {
View view = inflater
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view);
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
View view = inflater
.inflate(R.layout.item_status_notification, parent, false);
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
}
case VIEW_TYPE_FOLLOW: {
View view = inflater
.inflate(R.layout.item_follow, parent, false);
return new FollowViewHolder(view, statusDisplayOptions);
}
case VIEW_TYPE_FOLLOW_REQUEST: {
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false);
return new FollowRequestViewHolder(binding, this, true);
}
case VIEW_TYPE_PLACEHOLDER: {
View view = inflater
.inflate(R.layout.item_status_placeholder, parent, false);
return new PlaceholderViewHolder(view);
}
case VIEW_TYPE_REPORT: {
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
return new ReportNotificationViewHolder(binding);
}
default:
case VIEW_TYPE_UNKNOWN: {
View view = new View(parent.getContext());
view.setLayoutParams(
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
Utils.dpToPx(parent.getContext(), 24)
)
);
return new RecyclerView.ViewHolder(view) {
};
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
bindViewHolder(viewHolder, position, null);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
bindViewHolder(viewHolder, position, payloads);
}
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List<Object> payloads) {
Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null;
if (position < this.dataSource.getItemCount()) {
NotificationViewData notification = dataSource.getItemAt(position);
if (notification instanceof NotificationViewData.Placeholder) {
if (payloadForHolder == null) {
NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification);
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
holder.setup(statusListener, placeholder.isLoading());
}
return;
}
NotificationViewData.Concrete concreteNotification =
(NotificationViewData.Concrete) notification;
switch (viewHolder.getItemViewType()) {
case VIEW_TYPE_STATUS: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
if (status == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showStatusContent(false);
} else {
if (payloads == null) {
holder.showStatusContent(true);
}
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
}
if (concreteNotification.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
} else {
holder.hideStatusInfo();
}
break;
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
if (payloadForHolder == null) {
if (statusViewData == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showNotificationContent(false);
} else {
holder.showNotificationContent(true);
Status status = statusViewData.getActionable();
holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis());
holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if (concreteNotification.getType() == Notification.Type.STATUS ||
concreteNotification.getType() == Notification.Type.UPDATE) {
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else {
holder.setAvatars(status.getAccount().getAvatar(),
concreteNotification.getAccount().getAvatar());
}
}
holder.setMessage(concreteNotification, statusListener);
holder.setupButtons(notificationActionListener,
concreteNotification.getAccount().getId(),
concreteNotification.getId());
} else {
if (payloadForHolder instanceof List)
for (Object item : (List<?>) payloadForHolder) {
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
}
}
}
break;
}
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_REPORT: {
if (payloadForHolder == null) {
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
}
}
default:
}
}
}
@Override
public int getItemCount() {
return dataSource.getItemCount();
}
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
this.statusDisplayOptions = statusDisplayOptions.copy(
statusDisplayOptions.animateAvatars(),
mediaPreviewEnabled,
statusDisplayOptions.useAbsoluteTime(),
statusDisplayOptions.showBotOverlay(),
statusDisplayOptions.useBlurhash(),
CardViewMode.NONE,
statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.confirmFavourites(),
statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis(),
statusDisplayOptions.showStatsInline(),
statusDisplayOptions.showSensitiveMedia(),
statusDisplayOptions.openSpoiler()
);
}
public boolean isMediaPreviewEnabled() {
return this.statusDisplayOptions.mediaPreviewEnabled();
}
@Override
public int getItemViewType(int position) {
NotificationViewData notification = dataSource.getItemAt(position);
if (notification instanceof NotificationViewData.Concrete) {
NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification);
switch (concrete.getType()) {
case MENTION:
case POLL: {
return VIEW_TYPE_STATUS;
}
case STATUS:
case FAVOURITE:
case REBLOG:
case UPDATE: {
return VIEW_TYPE_STATUS_NOTIFICATION;
}
case FOLLOW:
case SIGN_UP: {
return VIEW_TYPE_FOLLOW;
}
case FOLLOW_REQUEST: {
return VIEW_TYPE_FOLLOW_REQUEST;
}
case REPORT: {
return VIEW_TYPE_REPORT;
}
default: {
return VIEW_TYPE_UNKNOWN;
}
}
} else if (notification instanceof NotificationViewData.Placeholder) {
return VIEW_TYPE_PLACEHOLDER;
} else {
throw new AssertionError("Unknown notification type");
}
}
public interface NotificationActionListener {
void onViewAccount(String id);
void onViewStatusForNotificationId(String notificationId);
void onViewReport(String reportId);
void onExpandedChange(boolean expanded, int position);
/**
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long
* status content is interacted with.
*
* @param isCollapsed Whether the status content is shown in a collapsed state or fully.
* @param position The position of the status in the list.
*/
void onNotificationContentCollapsedChange(boolean isCollapsed, int position);
}
private static class FollowViewHolder extends RecyclerView.ViewHolder {
private final TextView message;
private final TextView usernameView;
private final TextView displayNameView;
private final ImageView avatar;
private final StatusDisplayOptions statusDisplayOptions;
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
super(itemView);
message = itemView.findViewById(R.id.notification_text);
usernameView = itemView.findViewById(R.id.notification_username);
displayNameView = itemView.findViewById(R.id.notification_display_name);
avatar = itemView.findViewById(R.id.notification_avatar);
this.statusDisplayOptions = statusDisplayOptions;
}
void setMessage(TimelineAccount account, Boolean isSignUp) {
Context context = message.getContext();
String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
String wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
);
message.setText(emojifiedMessage);
String username = context.getString(R.string.post_username_format, account.getUsername());
usernameView.setText(username);
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
);
displayNameView.setText(emojifiedDisplayName);
int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
statusDisplayOptions.animateAvatars(), null);
}
void setupButtons(final NotificationActionListener listener, final String accountId) {
itemView.setOnClickListener(v -> listener.onViewAccount(accountId));
}
}
private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder
implements View.OnClickListener {
private final View container;
private final TextView message;
// private final View statusNameBar;
private final TextView displayName;
private final TextView username;
private final TextView timestampInfo;
private final TextView statusContent;
private final ImageView statusAvatar;
private final ImageView notificationAvatar;
private final TextView contentWarningDescriptionTextView;
private final Button contentWarningButton;
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
private final StatusDisplayOptions statusDisplayOptions;
private final AbsoluteTimeFormatter absoluteTimeFormatter;
private String accountId;
private String notificationId;
private NotificationActionListener notificationActionListener;
private StatusViewData.Concrete statusViewData;
private final int avatarRadius48dp;
private final int avatarRadius36dp;
private final int avatarRadius24dp;
StatusNotificationViewHolder(
View itemView,
StatusDisplayOptions statusDisplayOptions,
AbsoluteTimeFormatter absoluteTimeFormatter
) {
super(itemView);
message = itemView.findViewById(R.id.notification_top_text);
// statusNameBar = itemView.findViewById(R.id.status_name_bar);
displayName = itemView.findViewById(R.id.status_display_name);
username = itemView.findViewById(R.id.status_username);
timestampInfo = itemView.findViewById(R.id.status_meta_info);
statusContent = itemView.findViewById(R.id.notification_content);
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
container = itemView.findViewById(R.id.notification_container);
this.statusDisplayOptions = statusDisplayOptions;
this.absoluteTimeFormatter = absoluteTimeFormatter;
int darkerFilter = Color.rgb(123, 123, 123);
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
itemView.setOnClickListener(this);
message.setOnClickListener(this);
statusContent.setOnClickListener(this);
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
}
private void showNotificationContent(boolean show) {
// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE);
contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE);
statusContent.setVisibility(show ? View.VISIBLE : View.GONE);
statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE);
}
private void setDisplayName(String name, List<Emoji> emojis) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
displayName.setText(emojifiedName);
}
private void setUsername(String name) {
Context context = username.getContext();
String format = context.getString(R.string.post_username_format);
String usernameText = String.format(format, name);
username.setText(usernameText);
}
protected void setCreatedAt(@Nullable Date createdAt) {
if (statusDisplayOptions.useAbsoluteTime()) {
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
} else {
// This is the visible timestampInfo.
String readout;
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
CharSequence readoutAloud;
if (createdAt != null) {
long then = createdAt.getTime();
long now = new Date().getTime();
readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now,
android.text.format.DateUtils.SECOND_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE);
} else {
// unknown minutes~
readout = "?m";
readoutAloud = "? minutes";
}
timestampInfo.setText(readout);
timestampInfo.setContentDescription(readoutAloud);
}
}
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
Drawable icon = ContextCompat.getDrawable(context, drawable);
if (icon != null) {
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
}
return icon;
}
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
this.statusViewData = notificationViewData.getStatusViewData();
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
Notification.Type type = notificationViewData.getType();
Context context = message.getContext();
String format;
Drawable icon;
switch (type) {
default:
case FAVOURITE: {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
format = context.getString(R.string.notification_favourite_format);
break;
}
case REBLOG: {
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
format = context.getString(R.string.notification_reblog_format);
break;
}
case STATUS: {
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.chinwag_green);
format = context.getString(R.string.notification_subscription_format);
break;
}
case UPDATE: {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_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(), null);
if (statusDisplayOptions.showBotOverlay() && isBot) {
notificationAvatar.setVisibility(View.VISIBLE);
Glide.with(notificationAvatar)
.load(R.drawable.bot_badge)
.into(notificationAvatar);
} else {
notificationAvatar.setVisibility(View.GONE);
}
}
void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
statusAvatar.setPaddingRelative(0, 0, padding, padding);
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null);
notificationAvatar.setVisibility(View.VISIBLE);
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
avatarRadius24dp, statusDisplayOptions.animateAvatars(), null);
}
@Override
public void onClick(View v) {
if (notificationActionListener == null)
return;
if (v == container || v == statusContent) {
notificationActionListener.onViewStatusForNotificationId(notificationId);
}
else if (v == message) {
notificationActionListener.onViewAccount(accountId);
}
}
private void setupContentAndSpoiler(final LinkListener listener) {
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
if (!shouldShowContentIfSpoiler && hasSpoiler) {
statusContent.setVisibility(View.GONE);
} else {
statusContent.setVisibility(View.VISIBLE);
}
Spanned content = statusViewData.getContent();
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
contentCollapseButton.setOnClickListener(view -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
}
});
contentCollapseButton.setVisibility(View.VISIBLE);
if (statusViewData.isCollapsed()) {
contentCollapseButton.setText(R.string.post_content_warning_show_more);
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setText(R.string.post_content_warning_show_less);
statusContent.setFilters(NO_INPUT_FILTER);
}
} else {
contentCollapseButton.setVisibility(View.GONE);
statusContent.setFilters(NO_INPUT_FILTER);
}
CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify(
statusViewData.getStatus().getSpoilerText(),
statusViewData.getActionable().getEmojis(),
contentWarningDescriptionTextView,
statusDisplayOptions.animateEmojis()
);
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
}
}
@Override
public void onViewTag(@NonNull String tag) {
}
@Override
public void onViewAccount(@NonNull String id) {
}
@Override
public void onViewUrl(@NonNull String url) {
}
}

View file

@ -14,54 +14,33 @@
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
import com.google.android.material.progressindicator.IndeterminateDrawable
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.visible
/**
* Placeholder for different timelines.
* Placeholder for missing parts in timelines.
*
* Displays a "Load more" button for a particular status ID, or a
* circular progress wheel if the status' page is being loaded.
*
* The user can only have one "Load more" operation in progress at
* a time (determined by the adapter), so the contents of the view
* and the enabled state is driven by that.
* Displays a "Load more" button to load the gap, or a
* circular progress bar if the missing page is being loaded.
*/
class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more)
private val drawable = IndeterminateDrawable.createCircularDrawable(
itemView.context,
CircularProgressIndicatorSpec(itemView.context, null)
)
class PlaceholderViewHolder(
private val binding: ItemStatusPlaceholderBinding,
listener: StatusActionListener
) : RecyclerView.ViewHolder(binding.root) {
fun setup(listener: StatusActionListener, loading: Boolean) {
itemView.isEnabled = !loading
loadMoreButton.isEnabled = !loading
if (loading) {
loadMoreButton.text = ""
loadMoreButton.icon = drawable
return
}
loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text)
loadMoreButton.icon = null
// To allow the user to click anywhere in the layout to load more content set the click
// listener on the parent layout instead of loadMoreButton.
//
// See the comments in item_status_placeholder.xml for more details.
itemView.setOnClickListener {
itemView.isEnabled = false
loadMoreButton.isEnabled = false
loadMoreButton.icon = drawable
loadMoreButton.text = ""
init {
binding.loadMoreButton.setOnClickListener {
binding.loadMoreButton.hide()
binding.loadMoreProgressBar.show()
listener.onLoadMore(bindingAdapterPosition)
}
}
fun setup(loading: Boolean) {
binding.loadMoreButton.visible(!loading)
binding.loadMoreProgressBar.visible(loading)
}
}

View file

@ -19,7 +19,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
@ -58,7 +57,7 @@ class PreviewPollOptionsAdapter : RecyclerView.Adapter<PreviewViewHolder>() {
R.drawable.ic_radio_button_unchecked_18dp
}
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(textView, iconId, 0, 0, 0)
textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconId, 0, 0, 0)
textView.text = options[position]

View file

@ -6,9 +6,11 @@ import android.content.Context;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.view.Gravity;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
@ -17,14 +19,14 @@ import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.PopupMenu;
import androidx.appcompat.widget.TooltipCompat;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
@ -33,6 +35,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.card.MaterialCardView;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.imageview.ShapeableImageView;
import com.google.android.material.shape.CornerFamily;
@ -42,16 +45,16 @@ import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.entity.Attachment;
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.PreviewCard;
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.entity.TimelineAccount;
import com.keylesspalace.tusky.entity.Translation;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.AttachmentHelper;
import com.keylesspalace.tusky.util.BlurhashDrawable;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -74,7 +77,6 @@ import java.text.NumberFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.helpers.Utils;
@ -95,7 +97,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final SparkButton favouriteButton;
private final SparkButton bookmarkButton;
private final ImageButton moreButton;
private final ConstraintLayout mediaContainer;
protected final ConstraintLayout mediaContainer;
protected final MediaPreviewLayout mediaPreview;
private final TextView sensitiveMediaWarning;
private final View sensitiveMediaShow;
@ -113,19 +115,19 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final TextView pollDescription;
private final Button pollButton;
private final LinearLayout cardView;
private final LinearLayout cardInfo;
private final MaterialCardView cardView;
private final LinearLayout cardLayout;
private final ShapeableImageView cardImage;
private final TextView cardTitle;
private final TextView cardDescription;
private final TextView cardUrl;
private final TextView cardMetadata;
private final TextView cardAuthor;
private final TextView cardAuthorButton;
private final PollAdapter pollAdapter;
protected final LinearLayout filteredPlaceholder;
protected final TextView filteredPlaceholderLabel;
protected final Button filteredPlaceholderShowButton;
protected final ConstraintLayout statusContainer;
private final TextView translationStatusView;
private final Button untranslateButton;
private final TextView trailingHashtagView;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
@ -173,15 +175,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
pollButton = itemView.findViewById(R.id.status_poll_button);
cardView = itemView.findViewById(R.id.status_card_view);
cardInfo = itemView.findViewById(R.id.card_info);
cardLayout = itemView.findViewById(R.id.status_card_layout);
cardImage = itemView.findViewById(R.id.card_image);
cardTitle = itemView.findViewById(R.id.card_title);
cardDescription = itemView.findViewById(R.id.card_description);
cardUrl = itemView.findViewById(R.id.card_link);
cardMetadata = itemView.findViewById(R.id.card_metadata);
cardAuthor = itemView.findViewById(R.id.card_author);
cardAuthorButton = itemView.findViewById(R.id.card_author_button);
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();
@ -191,6 +191,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
translationStatusView = itemView.findViewById(R.id.status_translation_status);
untranslateButton = itemView.findViewById(R.id.status_button_untranslate);
trailingHashtagView = itemView.findViewById(R.id.status_trailing_hashtags_content);
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
@ -201,7 +202,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
}
protected void setDisplayName(@NonNull String name, @Nullable List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
protected void setDisplayName(@NonNull String name, @NonNull List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
);
@ -235,9 +236,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
);
contentWarningDescription.setText(emojiSpoiler);
contentWarningDescription.setVisibility(View.VISIBLE);
contentWarningButton.setVisibility(View.VISIBLE);
setContentWarningButtonText(expanded);
contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
boolean hasContent = !TextUtils.isEmpty(status.getContent());
if (hasContent) {
contentWarningButton.setVisibility(View.VISIBLE);
setContentWarningButtonText(expanded);
contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener));
} else {
contentWarningButton.setVisibility(View.GONE);
}
this.setTextVisible(true, expanded, status, statusDisplayOptions, listener);
} else {
contentWarningDescription.setVisibility(View.GONE);
@ -287,7 +293,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener, this.trailingHashtagView);
if (trailingHashtagView != null && status.isCollapsible() && status.isCollapsed()) {
trailingHashtagView.setVisibility(View.GONE);
}
for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, true);
}
@ -298,6 +307,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
} else {
hidePoll();
if (trailingHashtagView != null) {
trailingHashtagView.setVisibility(View.GONE);
}
LinkHelper.setClickableMentions(this.content, mentions, listener);
}
if (TextUtils.isEmpty(this.content.getText())) {
@ -399,13 +411,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
protected void setIsReply(boolean isReply) {
protected void setReplyButtonImage(boolean isReply) {
if (isReply) {
replyButton.setImageResource(R.drawable.ic_reply_all_24dp);
} else {
replyButton.setImageResource(R.drawable.ic_reply_24dp);
}
}
protected void setReplyCount(int repliesCount, boolean fullStats) {
@ -463,7 +474,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
private BitmapDrawable decodeBlurHash(String blurhash) {
return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash);
return new BlurhashDrawable(this.avatar.getContext(), blurhash);
}
private void loadImage(MediaPreviewImageView imageView,
@ -536,12 +547,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
final Attachment.Type type = attachment.getType();
if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) {
imageView.setForeground(ContextCompat.getDrawable(itemView.getContext(), R.drawable.play_indicator_overlay));
imageView.setForegroundGravity(Gravity.CENTER);
imageView.setForeground(AppCompatResources.getDrawable(itemView.getContext(), R.drawable.ic_play_indicator));
} else {
imageView.setForeground(null);
}
setAttachmentClickListener(imageView, listener, i, attachment, true);
final CharSequence formattedDescription = AttachmentHelper.getFormattedDescription(attachment, imageView.getContext());
setAttachmentClickListener(imageView, listener, i, formattedDescription, true);
if (sensitive) {
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
@ -611,17 +624,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// Set the icon next to the label.
int drawableId = getLabelIcon(attachments.get(0).getType());
mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0);
mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0);
setAttachmentClickListener(mediaLabel, listener, i, attachment, false);
setAttachmentClickListener(mediaLabel, listener, i, mediaDescriptions[i], false);
} else {
mediaLabel.setVisibility(View.GONE);
}
}
}
private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener,
int index, Attachment attachment, boolean animateTransition) {
private void setAttachmentClickListener(@NonNull View view, @NonNull StatusActionListener listener,
int index, CharSequence description, boolean animateTransition) {
view.setOnClickListener(v -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
@ -632,11 +645,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
});
view.setOnLongClickListener(v -> {
CharSequence description = AttachmentHelper.getFormattedDescription(attachment, view.getContext());
Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show();
return true;
});
TooltipCompat.setTooltipText(view, description);
}
protected void hideSensitiveMediaWarning() {
@ -670,7 +679,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
showConfirmReblog(listener, buttonState, position);
return false;
} else {
listener.onReblog(!buttonState, position);
listener.onReblog(!buttonState, position, Status.Visibility.PUBLIC);
return true;
}
} else {
@ -731,13 +740,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
popup.inflate(R.menu.status_reblog);
Menu menu = popup.getMenu();
if (buttonState) {
menu.findItem(R.id.menu_action_reblog).setVisible(false);
menu.setGroupVisible(R.id.menu_action_reblog_group, false);
} else {
menu.findItem(R.id.menu_action_unreblog).setVisible(false);
}
popup.setOnMenuItemClickListener(item -> {
listener.onReblog(!buttonState, position);
if (!buttonState) {
if (buttonState) {
listener.onReblog(false, position, Status.Visibility.PUBLIC);
} else {
Status.Visibility visibility;
if (item.getItemId() == R.id.menu_action_reblog_public) {
visibility = Status.Visibility.PUBLIC;
} else if (item.getItemId() == R.id.menu_action_reblog_unlisted) {
visibility = Status.Visibility.UNLISTED;
} else if (item.getItemId() == R.id.menu_action_reblog_private) {
visibility = Status.Visibility.PRIVATE;
} else {
visibility = Status.Visibility.PUBLIC;
}
listener.onReblog(true, position, visibility);
reblogButton.playAnimation();
reblogButton.setChecked(true);
}
@ -768,21 +789,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
popup.show();
}
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions) {
this.setupWithStatus(status, listener, statusDisplayOptions, null);
}
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
@NonNull List<Object> payloads,
final boolean showStatusInfo) {
if (payloads.isEmpty()) {
Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(actionable.getAccount().getUsername());
setMetaData(status, statusDisplayOptions, listener);
setIsReply(actionable.getInReplyToId() != null);
setReplyButtonImage(actionable.isReply());
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions);
@ -791,10 +808,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = actionable.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
if (attachments.isEmpty()) {
mediaContainer.setVisibility(View.GONE);
} else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
mediaContainer.setVisibility(View.VISIBLE);
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
if (attachments.isEmpty()) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
@ -802,6 +823,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
mediaLabel.setVisibility(View.GONE);
}
} else {
mediaContainer.setVisibility(View.VISIBLE);
setMediaLabel(attachments, sensitive, listener, status.isShowingContent());
// Hide all unused views.
mediaPreview.setVisibility(View.GONE);
@ -819,8 +842,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setSpoilerAndContent(status, statusDisplayOptions, listener);
setupFilterPlaceholder(status, listener, statusDisplayOptions);
setDescriptionForStatus(status, statusDisplayOptions);
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
@ -830,13 +851,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// and let RecyclerView ask for a new delegate.
itemView.setAccessibilityDelegate(null);
} else {
if (payloads instanceof List)
for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(status, statusDisplayOptions, listener);
for (Object item : payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(status, statusDisplayOptions, listener);
if (status.getStatus().getCard() != null && status.getStatus().getCard().getPublishedAt() != null) {
// there is a preview card showing the published time, we need to refresh it as well
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
}
break;
}
}
}
}
@ -863,28 +887,6 @@ 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(@NonNull List<Attachment> attachments) {
for (Attachment attachment : attachments) {
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
@ -1154,11 +1156,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return;
}
final Context context = cardView.getContext();
final Status actionable = status.getActionable();
final Card card = actionable.getCard();
final PreviewCard card = actionable.getCard();
if (cardViewMode != CardViewMode.NONE &&
actionable.getAttachments().size() == 0 &&
actionable.getAttachments().isEmpty() &&
actionable.getPoll() == null &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
@ -1167,45 +1171,79 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle());
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
cardDescription.setVisibility(View.GONE);
String providerName = card.getProviderName();
if (TextUtils.isEmpty(providerName)) {
providerName = Uri.parse(card.getUrl()).getHost();
}
if (TextUtils.isEmpty(providerName)) {
cardMetadata.setVisibility(View.GONE);
} else {
cardDescription.setVisibility(View.VISIBLE);
if (TextUtils.isEmpty(card.getDescription())) {
cardDescription.setText(card.getAuthorName());
cardMetadata.setVisibility(View.VISIBLE);
if (card.getPublishedAt() == null) {
cardMetadata.setText(providerName);
} else {
cardDescription.setText(card.getDescription());
String metadataJoiner = context.getString(R.string.metadata_joiner);
cardMetadata.setText(providerName + metadataJoiner + TimestampUtils.getRelativeTimeSpanString(context, card.getPublishedAt().getTime(), System.currentTimeMillis()));
}
}
cardUrl.setText(card.getUrl());
String cardAuthorName;
final TimelineAccount cardAuthorAccount;
if (card.getAuthors().isEmpty()) {
cardAuthorAccount = null;
cardAuthorName = card.getAuthorName();
} else {
cardAuthorName = card.getAuthors().get(0).getName();
cardAuthorAccount = card.getAuthors().get(0).getAccount();
if (cardAuthorAccount != null) {
cardAuthorName = cardAuthorAccount.getName();
}
}
final boolean hasNoAuthorName = TextUtils.isEmpty(cardAuthorName);
if (hasNoAuthorName && TextUtils.isEmpty(card.getDescription())) {
cardAuthor.setVisibility(View.GONE);
cardAuthorButton.setVisibility(View.GONE);
} else if (hasNoAuthorName) {
cardAuthor.setVisibility(View.VISIBLE);
cardAuthor.setText(card.getDescription());
cardAuthorButton.setVisibility(View.GONE);
} else if (cardAuthorAccount == null) {
cardAuthor.setVisibility(View.VISIBLE);
cardAuthor.setText(context.getString(R.string.preview_card_by_author, cardAuthorName));
cardAuthorButton.setVisibility(View.GONE);
} else {
cardAuthorButton.setVisibility(View.VISIBLE);
final String buttonText = context.getString(R.string.preview_card_more_by_author, cardAuthorName);
final CharSequence emojifiedButtonText = CustomEmojiHelper.emojify(buttonText, cardAuthorAccount.getEmojis(), cardAuthorButton, statusDisplayOptions.animateEmojis());
cardAuthorButton.setText(emojifiedButtonText);
cardAuthorButton.setOnClickListener(v-> listener.onViewAccount(cardAuthorAccount.getId()));
cardAuthor.setVisibility(View.GONE);
}
// Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
int radius = context.getResources().getDimensionPixelSize(R.dimen.inner_card_radius);
ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder();
if (card.getWidth() > card.getHeight()) {
cardView.setOrientation(LinearLayout.VERTICAL);
cardLayout.setOrientation(LinearLayout.VERTICAL);
cardImage.getLayoutParams().height = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_vertical_height);
cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius);
cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius);
} else {
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardLayout.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius);
cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius);
}
@ -1215,22 +1253,20 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
RequestBuilder<Drawable> builder = Glide.with(cardImage.getContext())
.load(card.getImage())
.dontTransform();
.load(card.getImage());
if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
builder = builder.placeholder(decodeBlurHash(card.getBlurhash()));
}
builder.into(cardImage);
builder.centerInside()
.into(cardImage);
} else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
.getDimensionPixelSize(R.dimen.inner_card_radius);
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardLayout.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
@ -1242,15 +1278,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Glide.with(cardImage.getContext())
.load(decodeBlurHash(card.getBlurhash()))
.dontTransform()
.into(cardImage);
} else {
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardLayout.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.setShapeAppearanceModel(new ShapeAppearanceModel());
@ -1265,11 +1298,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardView.setOnClickListener(visitLink);
// View embedded photos in our image viewer instead of opening the browser
cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ?
cardImage.setOnClickListener(card.getType().equals(PreviewCard.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ?
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
visitLink);
cardView.setClipToOutline(true);
} else {
cardView.setVisibility(View.GONE);
}
@ -1296,13 +1327,4 @@ 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);
}
}
}

View file

@ -9,13 +9,11 @@ import android.text.method.LinkMovementMethod;
import android.text.style.DynamicDrawableSpan;
import android.text.style.ImageSpan;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.ViewUtils;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
@ -25,11 +23,11 @@ import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.NoUnderlineURLSpan;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ViewExtensionsKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private final TextView reblogs;
@ -143,15 +141,16 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
@NonNull List<Object> payloads,
final boolean showStatusInfo) {
// We never collapse statuses in the detail view
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
status.copyWithCollapsed(false) :
status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads, showStatusInfo);
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) {
if (payloads.isEmpty()) {
Status actionable = uncollapsedStatus.getActionable();
if (!statusDisplayOptions.hideStats()) {

View file

@ -23,13 +23,12 @@ import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.NumberUtils;
@ -38,10 +37,9 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Collections;
import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils;
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];
@ -63,24 +61,37 @@ public class StatusViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
@NonNull List<Object> payloads,
final boolean showStatusInfo) {
if (payloads.isEmpty()) {
boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText());
boolean expanded = status.isExpanded();
setupCollapsedState(sensitive, expanded, status, listener);
Status reblogging = status.getRebloggingStatus();
if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) {
if (!showStatusInfo || status.getFilterAction() == Filter.Action.WARN) {
hideStatusInfo();
} else {
String rebloggedByDisplayName = reblogging.getAccount().getName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
Status rebloggingStatus = status.getRebloggingStatus();
boolean isReplyOnly = rebloggingStatus == null && status.isReply();
boolean isReplySelf = isReplyOnly && status.isSelfReply();
boolean hasStatusInfo = rebloggingStatus != null | isReplyOnly;
TimelineAccount statusInfoAccount = rebloggingStatus != null ? rebloggingStatus.getAccount() : status.getRepliedToAccount();
if (!hasStatusInfo) {
hideStatusInfo();
} else {
setStatusInfoContent(statusInfoAccount, isReplyOnly, isReplySelf, statusDisplayOptions);
}
if (isReplyOnly) {
statusInfo.setOnClickListener(null);
} else {
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
}
}
reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
@ -88,28 +99,38 @@ public class StatusViewHolder extends StatusBaseViewHolder {
setFavouritedCount(status.getActionable().getFavouritesCount());
setReblogsCount(status.getActionable().getReblogsCount());
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
super.setupWithStatus(status, listener, statusDisplayOptions, payloads, showStatusInfo);
}
private void setRebloggedByDisplayName(final CharSequence name,
final List<Emoji> accountEmoji,
final StatusDisplayOptions statusDisplayOptions) {
private void setStatusInfoContent(final TimelineAccount account,
final boolean isReply,
final boolean isSelfReply,
final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName);
CharSequence accountName = account != null ? account.getName() : "";
CharSequence wrappedName = StringUtils.unicodeWrap(accountName);
CharSequence translatedText = "";
if (!isReply) {
translatedText = context.getString(R.string.post_boosted_format, wrappedName);
} else if (isSelfReply) {
translatedText = context.getString(R.string.post_replied_self);
} else {
if (account != null && accountName.length() > 0) {
translatedText = context.getString(R.string.post_replied_format, wrappedName);
} else {
translatedText = context.getString(R.string.post_replied);
}
}
CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
translatedText,
account != null ? account.getEmojis() : Collections.emptyList(),
statusInfo,
statusDisplayOptions.animateEmojis()
);
statusInfo.setText(emojifiedText);
statusInfo.setVisibility(View.VISIBLE);
}
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
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));
statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0);
statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0);
statusInfo.setVisibility(View.VISIBLE);
}
@ -125,6 +146,10 @@ public class StatusViewHolder extends StatusBaseViewHolder {
statusInfo.setVisibility(View.GONE);
}
protected TextView getStatusInfo() {
return statusInfo;
}
private void setupCollapsedState(boolean sensitive,
boolean expanded,
final StatusViewData.Concrete status,

View file

@ -2,6 +2,9 @@ package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Poll
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -9,40 +12,64 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Updates the database cache in response to events.
* This is important for the home timeline and notifications to be up to date.
*/
@OptIn(ExperimentalStdlibApi::class)
class CacheUpdater @Inject constructor(
eventHub: EventHub,
accountManager: AccountManager,
appDatabase: AppDatabase
appDatabase: AppDatabase,
moshi: Moshi
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
val timelineDao = appDatabase.timelineDao()
private val timelineDao = appDatabase.timelineDao()
private val statusDao = appDatabase.timelineStatusDao()
private val notificationsDao = appDatabase.notificationsDao()
init {
scope.launch {
eventHub.events.collect { event ->
val accountId = accountManager.activeAccount?.id ?: return@collect
val tuskyAccountId = accountManager.activeAccount?.id ?: return@collect
when (event) {
is StatusChangedEvent -> {
val status = event.status
timelineDao.update(
accountId = accountId,
status = status
)
is StatusChangedEvent -> statusDao.update(
tuskyAccountId = tuskyAccountId,
status = event.status,
moshi = moshi
)
is UnfollowEvent -> timelineDao.removeStatusesAndReblogsByUser(tuskyAccountId, event.accountId)
is BlockEvent -> removeAllByUser(tuskyAccountId, event.accountId)
is MuteEvent -> removeAllByUser(tuskyAccountId, event.accountId)
is DomainMuteEvent -> {
timelineDao.deleteAllFromInstance(tuskyAccountId, event.instance)
notificationsDao.deleteAllFromInstance(tuskyAccountId, event.instance)
}
is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent ->
timelineDao.delete(accountId, event.statusId)
is StatusDeletedEvent -> {
timelineDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
notificationsDao.deleteAllWithStatus(tuskyAccountId, event.statusId)
}
is PollVoteEvent -> {
timelineDao.setVoted(accountId, event.statusId, event.poll)
val pollString = moshi.adapter<Poll>().toJson(event.poll)
statusDao.setVoted(tuskyAccountId, event.statusId, pollString)
}
}
}
}
}
private suspend fun removeAllByUser(tuskyAccountId: Long, accountId: String) {
timelineDao.removeAllByUser(tuskyAccountId, accountId)
notificationsDao.removeAllByUser(tuskyAccountId, accountId)
}
fun stop() {
this.scope.cancel()
}

View file

@ -1,23 +1,19 @@
package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.entity.Status
data class StatusChangedEvent(val status: Status) : Event
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
data class UnfollowEvent(val accountId: String) : Event
data class BlockEvent(val accountId: String) : Event
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 scheduledStatus: ScheduledStatus) : Event
data class StatusScheduledEvent(val scheduledStatusId: String) : 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

View file

@ -1,14 +1,10 @@
package com.keylesspalace.tusky.appstore
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import java.util.function.Consumer
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
interface Event
@ -21,13 +17,4 @@ class EventHub @Inject constructor() {
suspend fun dispatch(event: Event) {
_events.emit(event)
}
// TODO remove as soon as NotificationsFragment is Kotlin
fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer<Event>) {
lifecycleOwner.lifecycleScope.launch {
events.collect { event ->
consumer.accept(event)
}
}
}
}

View file

@ -16,13 +16,12 @@
package com.keylesspalace.tusky.components.account
import android.animation.ArgbEvaluator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.os.Build
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.TextWatcher
@ -36,25 +35,25 @@ import androidx.activity.viewModels
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.ActivityOptionsCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
@ -71,9 +70,8 @@ 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
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
@ -84,7 +82,9 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.copyToClipboard
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.ensureBottomMargin
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
@ -93,16 +93,10 @@ import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
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 dagger.hilt.android.AndroidEntryPoint
import java.text.NumberFormat
import java.text.ParseException
import java.text.SimpleDateFormat
@ -111,25 +105,18 @@ import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.launch
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var viewModelFactory: ViewModelFactory
@AndroidEntryPoint
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, LinkListener {
@Inject
lateinit var draftsAlert: DraftsAlert
private val viewModel: AccountViewModel by viewModels { viewModelFactory }
private val viewModel: AccountViewModel by viewModels()
private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate)
private lateinit var accountFieldAdapter: AccountFieldAdapter
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
private var followState: FollowState = FollowState.NOT_FOLLOWING
private var blocking: Boolean = false
private var muting: Boolean = false
@ -141,8 +128,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
private var animateAvatar: Boolean = false
private var animateEmojis: Boolean = false
// fields for scroll animation
private var hideFab: Boolean = false
// for scroll animation
private var oldOffset: Int = 0
@ColorInt
@ -180,10 +166,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
// Obtain information to fill out the profile.
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false)
animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
handleWindowInsets()
setupToolbar()
@ -204,9 +188,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
* Load colors and dimensions from resources
*/
private fun loadResources() {
toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK)
toolbarColor = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorSurface)
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK)
statusBarColorOpaque = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorPrimaryDark)
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
}
@ -248,7 +232,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
// If wellbeing mode is enabled, follow stats and posts count should be hidden
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false)
if (wellbeingEnabled) {
@ -304,25 +287,21 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
private fun handleWindowInsets() {
binding.accountFloatingActionButton.ensureBottomMargin()
ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets ->
val top = insets.getInsets(systemBars()).top
val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
val systemBarInsets = insets.getInsets(systemBars())
val top = systemBarInsets.top
binding.accountToolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = top
}
val right = insets.getInsets(systemBars()).right
val bottom = insets.getInsets(systemBars()).bottom
val left = insets.getInsets(systemBars()).left
binding.accountCoordinatorLayout.updatePadding(
right = right,
bottom = bottom,
left = left
)
binding.swipeToRefreshLayout.setProgressViewEndTarget(
false,
top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance)
)
WindowInsetsCompat.CONSUMED
insets.inset(0, top, 0, 0)
}
}
@ -335,28 +314,13 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
setDisplayShowTitleEnabled(false)
}
val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation)
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(
this,
appBarElevation
)
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
binding.accountToolbar.background = toolbarBackground
binding.accountToolbar.setBackgroundColor(Color.TRANSPARENT)
binding.accountToolbar.setNavigationIcon(R.drawable.ic_arrow_back_with_background)
binding.accountToolbar.setOverflowIcon(
AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background)
)
binding.accountToolbar.overflowIcon = AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background)
binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(
this,
appBarElevation
).apply {
val avatarBackground = MaterialShapeDrawable().apply {
fillColor = ColorStateList.valueOf(toolbarColor)
elevation = appBarElevation
shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius))
.build()
@ -378,15 +342,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
supportActionBar?.setDisplayShowTitleEnabled(false)
}
if (hideFab && !blocking) {
if (verticalOffset > oldOffset) {
binding.accountFloatingActionButton.show()
}
if (verticalOffset < oldOffset) {
binding.accountFloatingActionButton.hide()
}
}
val scaledAvatarSize = (avatarSize + verticalOffset) / avatarSize
binding.accountAvatarImageView.scaleX = scaledAvatarSize
@ -398,7 +353,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
1f
)
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@Suppress("DEPRECATION")
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
}
val evaluatedToolbarColor = argbEvaluator.evaluate(
transparencyPercent,
@ -406,7 +364,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
toolbarColor
) as Int
toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor)
binding.accountToolbar.setBackgroundColor(evaluatedToolbarColor)
binding.accountStatusBarScrim.setBackgroundColor(evaluatedToolbarColor)
binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0
}
@ -415,7 +374,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
private fun makeNotificationBarTransparent() {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = statusBarColorTransparent
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
@Suppress("DEPRECATION")
window.statusBarColor = statusBarColorTransparent
}
}
/**
@ -426,7 +388,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
viewModel.accountData.collect {
if (it == null) return@collect
when (it) {
is Success -> onAccountChanged(it.data)
is Success -> {
onAccountChanged(it.data)
binding.swipeToRefreshLayout.isEnabled = true
}
is Error -> {
Snackbar.make(
binding.accountCoordinatorLayout,
@ -435,6 +400,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
)
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
binding.swipeToRefreshLayout.isEnabled = true
}
is Loading -> { }
}
@ -477,13 +443,13 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
* Setup swipe to refresh layout
*/
private fun setupRefreshLayout() {
binding.swipeToRefreshLayout.isEnabled = false // will only be enabled after the first load completed
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
lifecycleScope.launch {
viewModel.isRefreshing.collect { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
viewModel.isRefreshing.collect {
binding.swipeToRefreshLayout.isRefreshing = it
}
}
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
private fun onAccountChanged(account: Account?) {
@ -497,15 +463,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
for (view in listOf(binding.accountUsernameTextView, binding.accountDisplayNameTextView)) {
view.setOnLongClickListener {
loadedAccount?.let { loadedAccount ->
val fullUsername = getFullUsername(loadedAccount)
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername))
Snackbar.make(
binding.root,
copyToClipboard(
getFullUsername(loadedAccount),
getString(R.string.account_username_copied),
Snackbar.LENGTH_SHORT
)
.show()
}
true
}
@ -682,6 +643,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
binding.accountFloatingActionButton.setOnClickListener { mention() }
binding.accountFollowButton.setOnClickListener {
val confirmFollows = preferences.getBoolean(PrefKeys.CONFIRM_FOLLOWS, false)
if (viewModel.isSelf) {
val intent = Intent(this@AccountActivity, EditProfileActivity::class.java)
startActivity(intent)
@ -695,7 +657,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
when (followState) {
FollowState.NOT_FOLLOWING -> {
viewModel.changeFollowState()
if (confirmFollows) {
showFollowWarningDialog()
} else {
viewModel.changeFollowState()
}
}
FollowState.REQUESTED -> {
showFollowRequestPendingDialog()
@ -721,11 +687,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
blockingDomain = relation.blockingDomain
showingReblogs = relation.showingReblogs
// If wellbeing mode is enabled, "follows you" text should not be visible
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false)
binding.accountFollowsYouTextView.visible(relation.followedBy && !wellbeingEnabled)
binding.accountFollowsYouTextView.visible(relation.followedBy)
// because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field
// it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call
@ -890,17 +852,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
if (!viewModel.isSelf && followState != FollowState.FOLLOWING) {
menu.removeItem(R.id.action_add_or_remove_from_list)
}
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() {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.dialog_message_cancel_follow_request)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
@ -908,18 +863,26 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
}
private fun showUnfollowWarningDialog() {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(R.string.dialog_unfollow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showFollowWarningDialog() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.dialog_follow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun toggleBlockDomain(instance: String) {
if (blockingDomain) {
viewModel.unblockDomain(instance)
} else {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.mute_domain_warning, instance))
.setPositiveButton(
getString(R.string.mute_domain_warning_dialog_ok)
@ -931,7 +894,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
private fun toggleBlock() {
if (viewModel.relationshipData.value?.data?.blocking != true) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username))
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() }
.setNegativeButton(android.R.string.cancel, null)
@ -1133,6 +1096,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
badge.isClickable = false
badge.isFocusable = false
badge.setEnsureMinTouchTargetSize(false)
badge.isCloseIconVisible = false
// reset some chip defaults so it looks better for our badge usecase
badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding)
@ -1143,8 +1107,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
return badge
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val KEY_ACCOUNT_ID = "id"

View file

@ -19,18 +19,16 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getDomain
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
@ -46,10 +44,8 @@ class AccountViewModel @Inject constructor(
private val _noteSaved = MutableStateFlow(false)
val noteSaved: StateFlow<Boolean> = _noteSaved.asStateFlow()
private val _isRefreshing = MutableSharedFlow<Boolean>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val isRefreshing: SharedFlow<Boolean> = _isRefreshing.asSharedFlow()
private var isDataLoading = false
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
lateinit var accountId: String
var isSelf = false
@ -76,7 +72,9 @@ class AccountViewModel @Inject constructor(
private fun obtainAccount(reload: Boolean = false) {
if (_accountData.value == null || reload) {
isDataLoading = true
if (reload) {
_isRefreshing.value = true
}
_accountData.value = Loading()
viewModelScope.launch {
@ -87,14 +85,12 @@ class AccountViewModel @Inject constructor(
isFromOwnDomain = domain == activeAccount.domain
_accountData.value = Success(account)
isDataLoading = false
_isRefreshing.emit(false)
_isRefreshing.value = false
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
_accountData.value = Error(cause = t)
isDataLoading = false
_isRefreshing.emit(false)
_isRefreshing.value = false
}
)
}
@ -316,7 +312,7 @@ class AccountViewModel @Inject constructor(
}
private fun reload(isReload: Boolean = false) {
if (isDataLoading) {
if (_isRefreshing.value) {
return
}
accountId.let {

View file

@ -24,48 +24,37 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.FragmentListsListBinding
import com.keylesspalace.tusky.databinding.ItemListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.visible
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class ListSelectionFragment : DialogFragment(), Injectable {
@AndroidEntryPoint
class ListSelectionFragment : DialogFragment() {
interface ListSelectionListener {
fun onListSelected(list: MastoList)
}
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
private var _binding: FragmentListsListBinding? = null
// This property is only valid between onCreateDialog and onDestroyView
private val binding get() = _binding!!
private val adapter = Adapter()
private val viewModel: ListsForAccountViewModel by viewModels()
private var selectListener: ListSelectionListener? = null
private var accountId: String? = null
@ -77,17 +66,17 @@ class ListSelectionFragment : DialogFragment(), Injectable {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
accountId = requireArguments().getString(ARG_ACCOUNT_ID)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
_binding = FragmentListsListBinding.inflate(layoutInflater)
val binding = FragmentListsListBinding.inflate(layoutInflater)
val adapter = Adapter()
binding.listsView.adapter = adapter
val dialogBuilder = AlertDialog.Builder(context)
val dialogBuilder = MaterialAlertDialogBuilder(context)
.setView(binding.root)
.setTitle(R.string.select_list_title)
.setNeutralButton(R.string.select_list_manage) { _, _ ->
@ -124,7 +113,7 @@ class ListSelectionFragment : DialogFragment(), Injectable {
binding.listsView.hide()
binding.messageView.apply {
show()
setup(error) { load() }
setup(error) { load(binding) }
}
}
}
@ -159,7 +148,7 @@ class ListSelectionFragment : DialogFragment(), Injectable {
}
lifecycleScope.launch {
load()
load(binding)
}
return dialog
@ -177,12 +166,7 @@ class ListSelectionFragment : DialogFragment(), Injectable {
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun load() {
private fun load(binding: FragmentListsListBinding) {
binding.progressBar.show()
binding.listsView.hide()
binding.messageView.hide()

View file

@ -24,6 +24,7 @@ import at.connyduck.calladapter.networkresult.onSuccess
import at.connyduck.calladapter.networkresult.runCatching
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
@ -48,6 +49,7 @@ data class ActionError(
}
}
@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class ListsForAccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.account.media
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
@ -28,15 +29,12 @@ 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
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.settings.PrefKeys
@ -49,6 +47,7 @@ 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.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@ -56,23 +55,23 @@ import kotlinx.coroutines.launch
/**
* Fragment with multiple columns of media previews for the specified account.
*/
@AndroidEntryPoint
class AccountMediaFragment :
Fragment(R.layout.fragment_timeline),
RefreshableFragment,
MenuProvider,
Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
MenuProvider {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var preferences: SharedPreferences
private val binding by viewBinding(FragmentTimelineBinding::bind)
private val viewModel: AccountMediaViewModel by viewModels { viewModelFactory }
private val viewModel: AccountMediaViewModel by viewModels()
private lateinit var adapter: AccountMediaGridAdapter
private var adapter: AccountMediaGridAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -82,14 +81,14 @@ class AccountMediaFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
adapter = AccountMediaGridAdapter(
val adapter = AccountMediaGridAdapter(
useBlurhash = useBlurhash,
context = view.context,
onAttachmentClickListener = ::onAttachmentClick
)
this.adapter = adapter
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val imageSpacing = view.context.resources.getDimensionPixelSize(
@ -147,6 +146,12 @@ class AccountMediaFragment :
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_account_media, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -202,13 +207,13 @@ class AccountMediaFragment :
}
}
Attachment.Type.UNKNOWN -> {
context?.openLink(selected.attachment.url)
context?.openLink(selected.attachment.unknownUrl)
}
}
}
override fun refreshContent() {
adapter.refresh()
adapter?.refresh()
}
companion object {

View file

@ -5,18 +5,19 @@ import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.setPadding
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.bumptech.glide.Glide
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.BlurhashDrawable
import com.keylesspalace.tusky.util.getFormattedDescription
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
@ -47,7 +48,7 @@ class AccountMediaGridAdapter(
private val baseItemBackgroundColor = MaterialColors.getColor(
context,
com.google.android.material.R.attr.colorSurface,
materialR.attr.colorSurface,
Color.BLACK
)
private val videoIndicator = AppCompatResources.getDrawable(
@ -85,7 +86,7 @@ class AccountMediaGridAdapter(
val blurhash = item.attachment.blurhash
val placeholder = if (useBlurhash && blurhash != null) {
decodeBlurHash(context, blurhash)
BlurhashDrawable(context, blurhash)
} else {
null
}
@ -141,11 +142,7 @@ class AccountMediaGridAdapter(
onAttachmentClickListener(item, imageView)
}
holder.binding.root.setOnLongClickListener { view ->
val description = item.attachment.getFormattedDescription(view.context)
Toast.makeText(view.context, description, Toast.LENGTH_LONG).show()
true
}
TooltipCompat.setTooltipText(holder.binding.root, imageView.contentDescription)
}
}
}

View file

@ -20,7 +20,7 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import retrofit2.HttpException
@ -59,7 +59,15 @@ class AccountMediaRemoteMediator(
}
val attachments = statuses.flatMap { status ->
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia)
status.attachments.map { attachment ->
AttachmentViewData(
attachment = attachment,
statusId = status.id,
statusUrl = status.url.orEmpty(),
sensitive = status.sensitive,
isRevealed = activeAccount.alwaysShowSensitiveMedia || !status.sensitive
)
}
}
if (loadType == LoadType.REFRESH) {

View file

@ -24,8 +24,10 @@ import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class AccountMediaViewModel @Inject constructor(
accountManager: AccountManager,
api: MastodonApi

View file

@ -22,14 +22,11 @@ import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
import com.keylesspalace.tusky.util.getSerializableExtraCompat
import dagger.hilt.android.AndroidEntryPoint
class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@AndroidEntryPoint
class AccountListActivity : BottomSheetActivity() {
enum class Type {
FOLLOWS,
@ -46,7 +43,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
val binding = ActivityAccountListBinding.inflate(layoutInflater)
setContentView(binding.root)
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
val type = intent.getSerializableExtraCompat<Type>(EXTRA_TYPE)!!
val id: String? = intent.getStringExtra(EXTRA_ID)
setSupportActionBar(binding.includedToolbar.toolbar)
@ -69,8 +66,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val EXTRA_TYPE = "type"
private const val EXTRA_ID = "id"

View file

@ -15,12 +15,12 @@
package com.keylesspalace.tusky.components.accountlist
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -42,7 +42,6 @@ import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHead
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
@ -50,21 +49,24 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.getSerializableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import retrofit2.Response
@AndroidEntryPoint
class AccountListFragment :
Fragment(R.layout.fragment_account_list),
AccountActionListener,
LinkListener,
Injectable {
LinkListener {
@Inject
lateinit var api: MastodonApi
@ -72,23 +74,26 @@ class AccountListFragment :
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var preferences: SharedPreferences
private val binding by viewBinding(FragmentAccountListBinding::bind)
private lateinit var type: Type
private var id: String? = null
private lateinit var scrollListener: EndlessOnScrollListener
private lateinit var adapter: AccountAdapter<*>
private var adapter: AccountAdapter<*>? = null
private var fetching = false
private var bottomId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
type = requireArguments().getSerializable(ARG_TYPE) as Type
type = requireArguments().getSerializableCompat(ARG_TYPE)!!
id = requireArguments().getString(ARG_ID)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.recyclerView.ensureBottomPadding()
binding.recyclerView.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
@ -97,17 +102,13 @@ class AccountListFragment :
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)
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val activeAccount = accountManager.activeAccount!!
adapter = when (type) {
val adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.FOLLOW_REQUESTS -> {
@ -122,22 +123,31 @@ class AccountListFragment :
}
else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
}
this.adapter = adapter
if (binding.recyclerView.adapter == null) {
binding.recyclerView.adapter = adapter
}
scrollListener = object : EndlessOnScrollListener(layoutManager) {
val scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId == null) {
return
}
fetchAccounts(bottomId)
fetchAccounts(adapter, bottomId)
}
}
binding.recyclerView.addOnScrollListener(scrollListener)
fetchAccounts()
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts(adapter) }
fetchAccounts(adapter)
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onViewTag(tag: String) {
@ -245,12 +255,12 @@ class AccountListFragment :
Log.e(TAG, "Failed to $verb account accountId $accountId")
}
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
viewLifecycleOwner.lifecycleScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
api.authorizeFollowRequest(id)
} else {
api.rejectFollowRequest(accountId)
api.rejectFollowRequest(id)
}.fold(
onSuccess = {
onRespondToFollowRequestSuccess(position)
@ -261,7 +271,7 @@ class AccountListFragment :
} else {
"reject"
}
Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
Log.e(TAG, "Failed to $verb account id $id.", throwable)
}
)
}
@ -300,7 +310,7 @@ class AccountListFragment :
return requireNotNull(id) { "id must not be null for type " + type.name }
}
private fun fetchAccounts(fromId: String? = null) {
private fun fetchAccounts(adapter: AccountAdapter<*>, fromId: String? = null) {
if (fetching) {
return
}
@ -316,19 +326,19 @@ class AccountListFragment :
val response = getFetchCallByListType(fromId)
if (!response.isSuccessful) {
onFetchAccountsFailure(Exception(response.message()))
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val accountList = response.body()
if (accountList == null) {
onFetchAccountsFailure(Exception(response.message()))
onFetchAccountsFailure(adapter, Exception(response.message()))
return@launch
}
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(accountList, linkHeader)
onFetchAccountsSuccess(adapter, accountList, linkHeader)
} catch (exception: Exception) {
if (exception is CancellationException) {
// Scope is cancelled, probably because the fragment is destroyed.
@ -336,12 +346,16 @@ class AccountListFragment :
// (CancellationException in a cancelled scope is normal and will be ignored)
throw exception
}
onFetchAccountsFailure(exception)
onFetchAccountsFailure(adapter, exception)
}
}
}
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
private fun onFetchAccountsSuccess(
adapter: AccountAdapter<*>,
accounts: List<TimelineAccount>,
linkHeader: String?
) {
adapter.setBottomLoading(false)
binding.swipeRefreshLayout.isRefreshing = false
@ -356,7 +370,7 @@ class AccountListFragment :
}
if (adapter is MutesAdapter) {
fetchRelationships(accounts.map { it.id })
fetchRelationships(adapter, accounts.map { it.id })
}
bottomId = fromId
@ -375,23 +389,30 @@ class AccountListFragment :
}
}
private fun fetchRelationships(ids: List<String>) {
lifecycleScope.launch {
private fun fetchRelationships(mutesAdapter: MutesAdapter, ids: List<String>) {
viewLifecycleOwner.lifecycleScope.launch {
api.relationships(ids)
.fold(::onFetchRelationshipsSuccess) { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
.fold(
onSuccess = { relationships ->
onFetchRelationshipsSuccess(mutesAdapter, relationships)
},
onFailure = { throwable ->
Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable)
}
)
}
}
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
val mutesAdapter = adapter as MutesAdapter
private fun onFetchRelationshipsSuccess(
mutesAdapter: MutesAdapter,
relationships: List<Relationship>
) {
val mutingNotificationsMap = HashMap<String, Boolean>()
relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) }
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}
private fun onFetchAccountsFailure(throwable: Throwable) {
private fun onFetchAccountsFailure(adapter: AccountAdapter<*>, throwable: Throwable) {
fetching = false
binding.swipeRefreshLayout.isRefreshing = false
Log.e(TAG, "Fetch failure", throwable)
@ -400,7 +421,7 @@ class AccountListFragment :
binding.messageView.show()
binding.messageView.setup(throwable) {
binding.messageView.hide()
this.fetchAccounts(null)
this.fetchAccounts(adapter, null)
}
}
}

View file

@ -21,7 +21,7 @@ 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
import com.keylesspalace.tusky.util.removeDuplicatesTo
/** Generic adapter with bottom loading indicator. */
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
@ -74,7 +74,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
}
fun update(newAccounts: List<TimelineAccount>) {
accountList = removeDuplicates(newAccounts)
accountList = newAccounts.removeDuplicatesTo(ArrayList())
notifyDataSetChanged()
}

View file

@ -44,6 +44,7 @@ class FollowRequestsAdapter(
)
return FollowRequestViewHolder(
binding,
accountActionListener,
linkListener,
showHeader = false
)

View file

@ -16,15 +16,16 @@
package com.keylesspalace.tusky.components.announcements
import android.annotation.SuppressLint
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.SpannableStringBuilder
import android.view.ContextThemeWrapper
import android.text.SpannableString
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.Target
import com.google.android.material.chip.Chip
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
@ -33,9 +34,11 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.clearEmojiTargets
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.setEmojiTargets
import com.keylesspalace.tusky.util.visible
interface AnnouncementActionListener : LinkListener {
@ -94,12 +97,19 @@ class AnnouncementAdapter(
// hide button if announcement badge limit is already reached
addReactionChip.visible(item.reactions.size < 8)
val requestManager = Glide.with(chips)
chips.clearEmojiTargets()
val targets = ArrayList<Target<Drawable>>(item.reactions.size)
item.reactions.forEachIndexed { i, reaction ->
(
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply {
?: Chip(chips.context).apply {
isCheckable = true
checkedIcon = null
isCloseIconVisible = false
setChipBackgroundColorResource(R.color.selectable_chip_background)
chips.addView(this, i)
}
)
@ -109,13 +119,14 @@ class AnnouncementAdapter(
} else {
// we set the EmojiSpan on a space, because otherwise the Chip won't have the right size
// https://github.com/tuskyapp/Tusky/issues/2308
val spanBuilder = SpannableStringBuilder(" ${reaction.count}")
val spannable = SpannableString(" ${reaction.count}")
val span = EmojiSpan(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
span.contentDescription = reaction.name
}
spanBuilder.setSpan(span, 0, 1, 0)
Glide.with(this)
val target = span.createGlideTarget(this, animateEmojis)
spannable.setSpan(span, 0, 1, 0)
requestManager
.asDrawable()
.load(
if (animateEmojis) {
@ -124,8 +135,9 @@ class AnnouncementAdapter(
reaction.staticUrl
}
)
.into(span.getTarget(animateEmojis))
this.text = spanBuilder
.into(target)
targets.add(target)
this.text = spannable
}
isChecked = reaction.me
@ -144,11 +156,18 @@ class AnnouncementAdapter(
chips.removeViewAt(item.reactions.size)
}
// Store Glide targets for later cancellation
chips.setEmojiTargets(targets)
addReactionChip.setOnClickListener {
listener.openReactionPicker(item.id, it)
}
}
override fun onViewRecycled(holder: BindingHolder<ItemAnnouncementBinding>) {
holder.binding.chipGroup.clearEmojiTargets()
}
override fun getItemCount() = items.size
fun updateList(items: List<Announcement>) {

View file

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.announcements
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
@ -27,46 +26,36 @@ import android.widget.PopupWindow
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.lifecycle.lifecycleScope
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
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
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
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class AnnouncementsActivity :
BottomSheetActivity(),
AnnouncementActionListener,
OnEmojiSelectedListener,
MenuProvider,
Injectable {
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory }
private val viewModel: AnnouncementsViewModel by viewModels()
private val binding by viewBinding(ActivityAnnouncementsBinding::inflate)
@ -98,14 +87,13 @@ class AnnouncementsActivity :
}
binding.swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
binding.announcementsList.ensureBottomPadding()
binding.announcementsList.setHasFixedSize(true)
binding.announcementsList.layoutManager = LinearLayoutManager(this)
val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
binding.announcementsList.addItemDecoration(divider)
val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
@ -161,12 +149,6 @@ class AnnouncementsActivity :
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 {

View file

@ -29,12 +29,14 @@ import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class AnnouncementsViewModel @Inject constructor(
private val instanceInfoRepo: InstanceInfoRepository,
private val mastodonApi: MastodonApi,
@ -56,7 +58,7 @@ class AnnouncementsViewModel @Inject constructor(
fun load() {
viewModelScope.launch {
_announcements.value = Loading()
mastodonApi.listAnnouncements()
mastodonApi.announcements()
.fold(
{
_announcements.value = Success(it)

View file

@ -16,15 +16,11 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.icu.text.BreakIterator
import android.net.Uri
import android.os.Build
@ -46,32 +42,33 @@ import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
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.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.core.widget.doAfterTextChanged
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
@ -80,6 +77,7 @@ 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.ComposeViewModel.QueuedMedia
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
@ -87,10 +85,8 @@ import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityComposeBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.DraftAttachment
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
@ -100,27 +96,34 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.util.MentionSpan
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.defaultFinders
import com.keylesspalace.tusky.util.getInitialLanguages
import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat
import com.keylesspalace.tusky.util.getParcelableCompat
import com.keylesspalace.tusky.util.getParcelableExtraCompat
import com.keylesspalace.tusky.util.getSerializableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.map
import com.keylesspalace.tusky.util.modernLanguageCode
import com.keylesspalace.tusky.util.setDrawableTint
import com.keylesspalace.tusky.util.setOnWindowInsetsChangeListener
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
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.migration.OptionalInject
import java.io.File
import java.io.IOException
import java.text.DecimalFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.flow.collect
@ -129,19 +132,17 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@OptionalInject
@AndroidEntryPoint
class ComposeActivity :
BaseActivity(),
ComposeOptionsListener,
ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable,
OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener,
CaptionDialog.Listener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var composeOptionsBehavior: BottomSheetBehavior<*>
private lateinit var addMediaBehavior: BottomSheetBehavior<*>
private lateinit var emojiBehavior: BottomSheetBehavior<*>
@ -152,25 +153,43 @@ class ComposeActivity :
private var photoUploadUri: Uri? = null
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
@VisibleForTesting
var highlightFinders = defaultFinders
@VisibleForTesting
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
private val viewModel: ComposeViewModel by viewModels()
private val binding by viewBinding(ActivityComposeBinding::inflate)
private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
private val takePicture =
private val takePictureLauncher =
registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
pickMedia(photoUploadUri!!)
viewModel.pickMedia(photoUploadUri!!)
}
}
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
private val pickMediaFilePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
pickMediaFileLauncher.launch(true)
} else {
Snackbar.make(
binding.activityCompose,
R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
setAction(R.string.action_retry) { onMediaPick() }
// necessary so snackbar is shown over everything
view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
show()
}
}
}
private val pickMediaFileLauncher = registerForActivityResult(PickMediaFiles()) { uris ->
if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
Toast.makeText(
this,
@ -182,9 +201,11 @@ class ComposeActivity :
Toast.LENGTH_SHORT
).show()
} else {
uris.forEach { uri ->
pickMedia(uri)
}
viewModel.pickMedia(
uris.map { uri ->
ComposeViewModel.MediaData(uri)
}
)
}
}
@ -195,22 +216,20 @@ class ComposeActivity :
viewModel.cropImageItemOld?.let { itemOld ->
val size = getMediaSize(contentResolver, uriNew)
lifecycleScope.launch {
viewModel.addMediaToQueue(
itemOld.type,
uriNew,
size,
itemOld.description,
// Intentionally reset focus when cropping
null,
itemOld
)
}
viewModel.addMediaToQueue(
type = itemOld.type,
uri = uriNew,
mediaSize = size,
description = itemOld.description,
// Intentionally reset focus when cropping
focus = null,
replaceItem = itemOld
)
}
} else if (result == CropImage.CancelledResult) {
Log.w("ComposeActivity", "Edit image cancelled by user")
Log.w(TAG, "Edit image cancelled by user")
} else {
Log.w("ComposeActivity", "Edit image failed: " + result.error)
Log.w(TAG, "Edit image failed: " + result.error)
displayTransientMessage(R.string.error_image_edit_failed)
}
viewModel.cropImageItemOld = null
@ -245,6 +264,18 @@ class ComposeActivity :
}
setContentView(binding.root)
binding.composeBottomBar.setOnWindowInsetsChangeListener { windowInsets ->
val insets = windowInsets.getInsets(systemBars() or ime())
val bottomBarHeight = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_height)
val bottomBarPadding = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_padding_vertical)
binding.composeBottomBar.updatePadding(bottom = insets.bottom + bottomBarPadding)
binding.addMediaBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.emojiView.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.composeOptionsBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.composeScheduleView.updatePadding(bottom = insets.bottom + bottomBarHeight)
binding.composeMainScrollView.updateLayoutParams<ViewGroup.MarginLayoutParams> { bottomMargin = insets.bottom + bottomBarHeight }
}
setupActionBar()
setupAvatar(activeAccount)
@ -273,17 +304,13 @@ 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? = IntentCompat.getParcelableExtra(
intent,
COMPOSE_OPTIONS_EXTRA,
ComposeOptions::class.java
)
val composeOptions: ComposeOptions? = intent.getParcelableExtraCompat(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupButtons()
subscribeToUpdates(mediaAdapter)
if (accountManager.shouldDisplaySelfUsername(this)) {
if (accountManager.shouldDisplaySelfUsername()) {
binding.composeUsernameView.text = getString(
R.string.compose_active_account_description,
activeAccount.fullName
@ -300,7 +327,7 @@ class ComposeActivity :
}
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
binding.composeScheduleView.setDateTime(composeOptions.scheduledAt)
}
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
@ -311,11 +338,9 @@ class ComposeActivity :
/* Finally, overwrite state with data from saved instance state. */
savedInstanceState?.let {
photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java)
photoUploadUri = it.getParcelableCompat(PHOTO_UPLOAD_URI_KEY)
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
setStatusVisibility(this)
}
setStatusVisibility(it.getSerializableCompat(VISIBILITY_KEY)!!)
it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply {
viewModel.contentWarningChanged(this)
@ -340,22 +365,15 @@ class ComposeActivity :
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
when (intent.action) {
Intent.ACTION_SEND -> {
IntentCompat.getParcelableExtra(
intent,
Intent.EXTRA_STREAM,
Uri::class.java
)?.let { uri ->
pickMedia(uri)
intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
viewModel.pickMedia(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
IntentCompat.getParcelableArrayListExtra(
intent,
Intent.EXTRA_STREAM,
Uri::class.java
)?.forEach { uri ->
pickMedia(uri)
}
intent.getParcelableArrayListExtraCompat<Uri>(Intent.EXTRA_STREAM)
?.map { uri ->
ComposeViewModel.MediaData(uri)
}?.let(viewModel::pickMedia)
}
}
}
@ -463,9 +481,9 @@ class ComposeActivity :
binding.composeEditField.setAdapter(
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showBotBadge = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
)
)
binding.composeEditField.setTokenizer(ComposeTokenizer())
@ -474,9 +492,9 @@ class ComposeActivity :
binding.composeEditField.setSelection(binding.composeEditField.length())
val mentionColour = binding.composeEditField.linkTextColors.defaultColor
highlightSpans(binding.composeEditField.text, mentionColour)
binding.composeEditField.text.highlightSpans(mentionColour, highlightFinders)
binding.composeEditField.doAfterTextChanged { editable ->
highlightSpans(editable!!, mentionColour)
editable!!.highlightSpans(mentionColour, highlightFinders)
updateVisibleCharactersLeft()
viewModel.updateContent(editable.toString())
}
@ -558,16 +576,25 @@ class ComposeActivity :
lifecycleScope.launch {
viewModel.uploadError.collect { throwable ->
if (throwable is UploadServerError) {
displayTransientMessage(throwable.errorMessage)
} else {
displayTransientMessage(
getString(
R.string.error_media_upload_sending_fmt,
throwable.message
)
val errorString = when (throwable) {
is UploadServerError -> throwable.errorMessage
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024)
val formattedSize = decimalFormat.format(allowedSizeInMb)
getString(R.string.error_multimedia_size_limit, formattedSize)
}
is VideoOrImageException -> getString(
R.string.error_media_upload_image_or_video
)
is CouldNotOpenFileException -> getString(R.string.error_media_upload_opening)
is MediaTypeException -> getString(R.string.error_media_upload_opening)
else -> getString(
R.string.error_media_upload_sending_fmt,
throwable.message
)
}
displayTransientMessage(errorString)
}
}
@ -586,6 +613,11 @@ class ComposeActivity :
scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView)
emojiBehavior = BottomSheetBehavior.from(binding.emojiView)
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
val bottomSheetCallback = object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
updateOnBackPressedCallbackState()
@ -823,31 +855,29 @@ class ComposeActivity :
binding.descriptionMissingWarningButton.hide()
} else {
binding.composeHideMediaButton.show()
@ColorInt val color = if (contentWarningShown) {
@AttrRes val color = if (contentWarningShown) {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
binding.composeHideMediaButton.isClickable = false
getColor(R.color.transparent_chinwag_green)
materialR.attr.colorPrimary
} else {
binding.composeHideMediaButton.isClickable = true
if (markMediaSensitive) {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
getColor(R.color.chinwag_green)
materialR.attr.colorPrimary
} else {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
MaterialColors.getColor(
binding.composeHideMediaButton,
android.R.attr.textColorTertiary
)
android.R.attr.textColorTertiary
}
}
binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
binding.composeHideMediaButton.drawable.setTint(
MaterialColors.getColor(
binding.composeHideMediaButton,
color
)
)
var oneMediaWithoutDescription = false
for (media in viewModel.media.value) {
if (media.description.isNullOrEmpty()) {
oneMediaWithoutDescription = true
break
}
val oneMediaWithoutDescription = viewModel.media.value.any { media ->
media.description.isNullOrEmpty()
}
binding.descriptionMissingWarningButton.visibility = if (oneMediaWithoutDescription) View.VISIBLE else View.GONE
}
@ -858,15 +888,16 @@ class ComposeActivity :
// Can't reschedule a published status
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
} else {
@ColorInt val color = if (binding.composeScheduleView.time == null) {
@ColorInt val color =
MaterialColors.getColor(
binding.composeScheduleButton,
android.R.attr.textColorTertiary
if (binding.composeScheduleView.time == null) {
android.R.attr.textColorTertiary
} else {
materialR.attr.colorPrimary
}
)
} else {
getColor(R.color.chinwag_green)
}
binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
binding.composeScheduleButton.drawable.setTint(color)
}
}
@ -937,7 +968,7 @@ class ComposeActivity :
val errorMessage =
getString(
R.string.error_no_custom_emojis,
accountManager.activeAccount!!.domain
activeAccount
)
displayTransientMessage(errorMessage)
} else {
@ -965,32 +996,16 @@ class ComposeActivity :
}
private fun onMediaPick() {
addMediaBehavior.addBottomSheetCallback(
object : 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) {}
}
)
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
pickMediaFilePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
} else {
pickMediaFileLauncher.launch(true)
}
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
}
private fun openPollDialog() = lifecycleScope.launch {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
val instanceParams = viewModel.instanceInfo.first()
showAddPollDialog(
context = this@ComposeActivity,
@ -1039,7 +1054,7 @@ class ComposeActivity :
}
override fun onVisibilityChanged(visibility: Status.Visibility) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
viewModel.changeStatusVisibility(visibility)
}
@ -1061,7 +1076,7 @@ class ComposeActivity :
binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength)
val textColor = if (remainingLength < 0) {
getColor(R.color.tusky_red)
getColor(R.color.warning_color)
} else {
MaterialColors.getColor(
binding.composeCharactersLeftView,
@ -1096,12 +1111,27 @@ class ComposeActivity :
if (contentInfo.clip.description.hasMimeType("image/*")) {
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,
contentInfo.clip.description.label as String?
)
val description = (contentInfo.clip.description.label as String?)?.let {
// The Gboard android keyboard attaches this text whenever the user
// pastes something from the keyboard's suggestion bar.
// Due to different end user locales, the exact text may vary, but at
// least in version 13.4.08, all of the translations contained the
// string "Gboard".
if ("Gboard" in it) {
null
} else {
it
}
}
viewModel.pickMedia(
content.clip.map { clipItem ->
ComposeViewModel.MediaData(
uri = clipItem.uri,
description = description
)
}
)
}
return split.second
}
@ -1130,33 +1160,8 @@ class ComposeActivity :
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
pickMediaFile.launch(true)
} else {
Snackbar.make(
binding.activityCompose,
R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
setAction(R.string.action_retry) { onMediaPick() }
// necessary so snackbar is shown over everything
view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
show()
}
}
}
}
private fun initiateCameraApp() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
val photoFile: File = try {
createNewImageFile(this)
@ -1170,8 +1175,7 @@ class ComposeActivity :
this,
BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile
)
takePicture.launch(photoUploadUri)
).also { uri -> takePictureLauncher.launch(uri) }
}
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
@ -1198,7 +1202,7 @@ class ComposeActivity :
}
)
binding.addPollTextActionTextView.setTextColor(textColor)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].setTint(textColor)
}
private fun editImageInQueue(item: QueuedMedia) {
@ -1231,65 +1235,28 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun sanitizePickMediaDescription(description: String?): String? {
if (description == null) {
return null
}
// The Gboard android keyboard attaches this text whenever the user
// pastes something from the keyboard's suggestion bar.
// Due to different end user locales, the exact text may vary, but at
// least in version 13.4.08, all of the translations contained the
// string "Gboard".
if ("Gboard" in description) {
return null
}
return description
}
private fun pickMedia(uri: Uri, description: String? = null) {
val sanitizedDescription = sanitizePickMediaDescription(description)
lifecycleScope.launch {
viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable ->
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024)
val formattedSize = decimalFormat.format(allowedSizeInMb)
getString(R.string.error_multimedia_size_limit, formattedSize)
}
is VideoOrImageException -> getString(
R.string.error_media_upload_image_or_video
)
else -> getString(R.string.error_media_upload_opening)
}
displayTransientMessage(errorString)
}
}
}
private fun showContentWarning(show: Boolean) {
TransitionManager.beginDelayedTransition(
binding.composeContentWarningBar.parent as ViewGroup
)
@ColorInt val color = if (show) {
@AttrRes val color = if (show) {
binding.composeContentWarningBar.show()
binding.composeContentWarningField.setSelection(
binding.composeContentWarningField.text.length
)
binding.composeContentWarningField.requestFocus()
getColor(R.color.chinwag_green)
materialR.attr.colorPrimary
} else {
binding.composeContentWarningBar.hide()
binding.composeEditField.requestFocus()
MaterialColors.getColor(
binding.composeContentWarningButton,
android.R.attr.textColorTertiary
)
android.R.attr.textColorTertiary
}
binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
binding.composeContentWarningButton.drawable.setTint(
MaterialColors.getColor(
binding.composeHideMediaButton,
color
)
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -1344,14 +1311,14 @@ class ComposeActivity :
private fun getSaveAsDraftOrDiscardDialog(
contentText: String,
contentWarning: String
): AlertDialog.Builder {
): MaterialAlertDialogBuilder {
val warning = if (viewModel.media.value.isNotEmpty()) {
R.string.compose_save_draft_loses_media
} else {
R.string.compose_save_draft
}
return AlertDialog.Builder(this)
return MaterialAlertDialogBuilder(this)
.setMessage(warning)
.setPositiveButton(R.string.action_save) { _, _ ->
viewModel.stopUploads()
@ -1370,14 +1337,14 @@ class ComposeActivity :
private fun getUpdateDraftOrDiscardDialog(
contentText: String,
contentWarning: String
): AlertDialog.Builder {
): MaterialAlertDialogBuilder {
val warning = if (viewModel.media.value.isNotEmpty()) {
R.string.compose_save_draft_loses_media
} else {
R.string.compose_save_draft
}
return AlertDialog.Builder(this)
return MaterialAlertDialogBuilder(this)
.setMessage(warning)
.setPositiveButton(R.string.action_save) { _, _ ->
viewModel.stopUploads()
@ -1393,8 +1360,8 @@ class ComposeActivity :
* User is editing a post (scheduled, or posted), and can either go back to editing, or
* discard the changes.
*/
private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder {
return AlertDialog.Builder(this)
private fun getContinueEditingOrDiscardDialog(): MaterialAlertDialogBuilder {
return MaterialAlertDialogBuilder(this)
.setMessage(R.string.compose_unsaved_changes)
.setPositiveButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing
@ -1409,8 +1376,8 @@ 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)
private fun getDeleteEmptyDraftOrContinueEditing(): MaterialAlertDialogBuilder {
return MaterialAlertDialogBuilder(this)
.setMessage(R.string.compose_delete_draft)
.setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteDraft()
@ -1429,19 +1396,7 @@ class ComposeActivity :
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
lifecycleScope.launch {
val dialog = if (viewModel.shouldShowSaveDraftDialog()) {
ProgressDialog.show(
this@ComposeActivity,
null,
getString(R.string.saving_draft),
true,
false
)
} else {
null
}
viewModel.saveDraft(contentText, contentWarning)
dialog?.cancel()
finish()
}
}
@ -1462,30 +1417,6 @@ class ComposeActivity :
}
}
data class QueuedMedia(
val localId: Int,
val uri: Uri,
val type: Type,
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null,
val focus: Attachment.Focus? = null,
val state: State
) {
enum class Type {
IMAGE,
VIDEO,
AUDIO
}
enum class State {
UPLOADING,
UNPROCESSED,
PROCESSED,
PUBLISHED
}
}
override fun onTimeSet(time: String?) {
viewModel.updateScheduledAt(time)
if (verifyScheduledTime()) {
@ -1550,7 +1481,6 @@ class ComposeActivity :
companion object {
private const val TAG = "ComposeActivity" // logging tag
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"

View file

@ -37,7 +37,9 @@ class ComposeAutoCompleteAdapter(
private val autocompletionProvider: AutocompletionProvider,
private val animateAvatar: Boolean,
private val animateEmojis: Boolean,
private val showBotBadge: Boolean
private val showBotBadge: Boolean,
// if true, @ # : are returned in the result, otherwise only the raw value
private val withDecoration: Boolean = true,
) : BaseAdapter(), Filterable {
private var resultList: List<AutocompleteResult> = emptyList()
@ -52,36 +54,35 @@ class ComposeAutoCompleteAdapter(
return position.toLong()
}
override fun getFilter(): Filter {
return object : Filter() {
override fun getFilter() = object : Filter() {
override fun convertResultToString(resultValue: Any): CharSequence {
return when (resultValue) {
is AutocompleteResult.AccountResult -> formatUsername(resultValue)
is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
else -> ""
}
override fun convertResultToString(resultValue: Any): CharSequence {
return when (resultValue) {
is AutocompleteResult.AccountResult -> if (withDecoration) "@${resultValue.account.username}" else resultValue.account.username
is AutocompleteResult.HashtagResult -> if (withDecoration) "#${resultValue.hashtag}" else resultValue.hashtag
is AutocompleteResult.EmojiResult -> if (withDecoration) ":${resultValue.emoji.shortcode}:" else resultValue.emoji.shortcode
else -> ""
}
}
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
val results = autocompletionProvider.search(constraint.toString())
filterResults.values = results
filterResults.count = results.size
}
return filterResults
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
val results = autocompletionProvider.search(constraint.toString())
filterResults.values = results
filterResults.count = results.size
}
return filterResults
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
if (results.count > 0) {
resultList = results.values as List<AutocompleteResult>
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
@Suppress("UNCHECKED_CAST")
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
if (results.count > 0) {
resultList = results.values as List<AutocompleteResult>
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
}
@ -121,7 +122,7 @@ class ComposeAutoCompleteAdapter(
}
is ItemAutocompleteHashtagBinding -> {
val result = getItem(position) as AutocompleteResult.HashtagResult
binding.root.text = formatHashtag(result)
binding.root.text = context.getString(R.string.hashtag_format, result.hashtag)
}
is ItemAutocompleteEmojiBinding -> {
val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
@ -161,17 +162,5 @@ class ComposeAutoCompleteAdapter(
private const val ACCOUNT_VIEW_TYPE = 0
private const val HASHTAG_VIEW_TYPE = 1
private const val EMOJI_VIEW_TYPE = 2
private fun formatUsername(result: AutocompleteResult.AccountResult): String {
return String.format("@%s", result.account.username)
}
private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
return String.format("#%s", result.hashtag)
}
private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
return String.format(":%s:", result.emoji.shortcode)
}
}
}

View file

@ -22,7 +22,6 @@ 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
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
@ -38,8 +37,10 @@ import com.keylesspalace.tusky.service.MediaToSend
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -54,8 +55,8 @@ import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@HiltViewModel
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
@ -90,7 +91,7 @@ class ComposeViewModel @Inject constructor(
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
private val _markMediaAsSensitive =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity == true)
val markMediaAsSensitive: StateFlow<Boolean> = _markMediaAsSensitive.asStateFlow()
private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN)
@ -125,31 +126,31 @@ class ComposeViewModel @Inject constructor(
private var setupComplete = false
suspend fun pickMedia(
mediaUri: Uri,
description: String? = null,
focus: Attachment.Focus? = null
): Result<QueuedMedia> = withContext(
Dispatchers.IO
) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
val mediaItems = _media.value
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
mediaItems[0].type == QueuedMedia.Type.IMAGE
) {
Result.failure(VideoOrImageException())
} else {
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
Result.success(queuedMedia)
}
} catch (e: Exception) {
Result.failure(e)
fun pickMedia(uri: Uri) {
pickMedia(listOf(MediaData(uri)))
}
fun pickMedia(mediaList: List<MediaData>) = viewModelScope.launch(Dispatchers.IO) {
val instanceInfo = instanceInfo.first()
mediaList.map { m ->
async { mediaUploader.prepareMedia(m.uri, instanceInfo) }
}.forEachIndexed { index, preparedMedia ->
preparedMedia.await().fold({ (type, uri, size) ->
if (type != QueuedMedia.Type.IMAGE &&
_media.value.firstOrNull()?.type == QueuedMedia.Type.IMAGE
) {
_uploadError.emit(VideoOrImageException())
} else {
val pickedMedia = mediaList[index]
addMediaToQueue(type, uri, size, pickedMedia.description, pickedMedia.focus)
}
}, { error ->
_uploadError.emit(error)
})
}
}
suspend fun addMediaToQueue(
fun addMediaToQueue(
type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
@ -157,20 +158,17 @@ class ComposeViewModel @Inject constructor(
focus: Attachment.Focus? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description,
focus = focus,
state = QueuedMedia.State.UPLOADING
)
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description,
focus = focus,
state = QueuedMedia.State.UPLOADING
)
stashMediaItem = mediaItem
if (replaceItem != null) {
mediaUploader.cancelUploadScope(replaceItem.localId)
mediaList.map {
@ -180,7 +178,6 @@ class ComposeViewModel @Inject constructor(
mediaList + mediaItem
}
}
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
viewModelScope.launch {
mediaUploader
@ -191,6 +188,7 @@ class ComposeViewModel @Inject constructor(
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(
id = event.mediaId,
@ -327,13 +325,6 @@ class ComposeViewModel @Inject constructor(
mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray())
}
fun shouldShowSaveDraftDialog(): Boolean {
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog
return _media.value.any { mediaValue ->
mediaValue.uri.scheme == "https"
}
}
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
@ -386,7 +377,7 @@ class ComposeViewModel @Inject constructor(
val tootToSend = StatusToSend(
text = content,
warningText = spoilerText,
visibility = _statusVisibility.value.serverString,
visibility = _statusVisibility.value.stringValue,
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value),
media = attachedMedia,
scheduledAt = _scheduledAt.value,
@ -453,6 +444,7 @@ class ComposeViewModel @Inject constructor(
emptyList()
})
}
':' -> {
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1)
@ -465,6 +457,7 @@ class ComposeViewModel @Inject constructor(
AutocompleteResult.EmojiResult(emoji)
}
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
emptyList()
@ -478,16 +471,20 @@ class ComposeViewModel @Inject constructor(
}
composeKind = composeOptions?.kind ?: ComposeKind.NEW
inReplyToId = composeOptions?.inReplyToId
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val activeAccount = accountManager.activeAccount!!
val preferredVisibility = if (inReplyToId != null) {
activeAccount.defaultReplyPrivacy.toVisibilityOr(activeAccount.defaultPostPrivacy)
} else {
activeAccount.defaultPostPrivacy
}
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
startingVisibility = Status.Visibility.byNum(
preferredVisibility.num.coerceAtLeast(replyVisibility.num)
startingVisibility = Status.Visibility.fromInt(
preferredVisibility.int.coerceAtLeast(replyVisibility.int)
)
inReplyToId = composeOptions?.inReplyToId
modifiedInitialState = composeOptions?.modifiedInitialState == true
val contentWarning = composeOptions?.contentWarning
@ -502,11 +499,9 @@ class ComposeViewModel @Inject constructor(
val draftAttachments = composeOptions?.draftAttachments
if (draftAttachments != null) {
// when coming from DraftActivity
viewModelScope.launch {
draftAttachments.forEach { attachment ->
pickMedia(attachment.uri, attachment.description, attachment.focus)
}
}
draftAttachments.map { attachment ->
MediaData(attachment.uri, attachment.description, attachment.focus)
}.let(::pickMedia)
} else {
composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft or ScheduledTootActivity
@ -523,10 +518,11 @@ class ComposeViewModel @Inject constructor(
scheduledTootId = composeOptions?.scheduledTootId
originalStatusId = composeOptions?.statusId
startingText = composeOptions?.content
currentContent = composeOptions?.content
postLanguage = composeOptions?.language
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
if (tootVisibility.int != Status.Visibility.UNKNOWN.int) {
startingVisibility = tootVisibility
}
_statusVisibility.value = startingVisibility
@ -584,6 +580,36 @@ class ComposeViewModel @Inject constructor(
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
}
data class QueuedMedia(
val localId: Int,
val uri: Uri,
val type: Type,
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null,
val focus: Attachment.Focus? = null,
val state: State
) {
enum class Type {
IMAGE,
VIDEO,
AUDIO
}
enum class State {
UPLOADING,
UNPROCESSED,
PROCESSED,
PUBLISHED
}
}
data class MediaData(
val uri: Uri,
val description: String? = null,
val focus: Attachment.Focus? = null
)
}
/**

View file

@ -21,8 +21,8 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupMenu
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -31,18 +31,25 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter(
context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit,
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
private val onAddCaption: (ComposeViewModel.QueuedMedia) -> Unit,
private val onAddFocus: (ComposeViewModel.QueuedMedia) -> Unit,
private val onEditImage: (ComposeViewModel.QueuedMedia) -> Unit,
private val onRemove: (ComposeViewModel.QueuedMedia) -> Unit
) : ListAdapter<ComposeViewModel.QueuedMedia, MediaPreviewAdapter.PreviewViewHolder>(
object : DiffUtil.ItemCallback<ComposeViewModel.QueuedMedia>() {
override fun areItemsTheSame(
oldItem: ComposeViewModel.QueuedMedia,
newItem: ComposeViewModel.QueuedMedia
) = oldItem.localId == newItem.localId
fun submitList(list: List<ComposeActivity.QueuedMedia>) {
this.differ.submitList(list)
override fun areContentsTheSame(
oldItem: ComposeViewModel.QueuedMedia,
newItem: ComposeViewModel.QueuedMedia
) = oldItem == newItem
}
) {
private fun onMediaClick(position: Int, view: View) {
val item = differ.currentList[position]
private fun onMediaClick(item: ComposeViewModel.QueuedMedia, view: View) {
val popup = PopupMenu(view.context, view)
val addCaptionId = 1
val addFocusId = 2
@ -50,9 +57,9 @@ class MediaPreviewAdapter(
val removeId = 4
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
if (item.type == ComposeViewModel.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
if (item.state != ComposeViewModel.QueuedMedia.State.PUBLISHED) {
// Already-published items can't be edited
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
}
@ -73,17 +80,15 @@ class MediaPreviewAdapter(
private val thumbnailViewSize =
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
override fun getItemCount(): Int = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
return PreviewViewHolder(ProgressImageView(parent.context))
}
override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) {
val item = differ.currentList[position]
val item = getItem(position)
holder.progressImageView.setChecked(!item.description.isNullOrEmpty())
holder.progressImageView.setProgress(item.uploadPercent)
if (item.type == ComposeActivity.QueuedMedia.Type.AUDIO) {
if (item.type == ComposeViewModel.QueuedMedia.Type.AUDIO) {
// TODO: Fancy waveform display?
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
@ -108,26 +113,11 @@ class MediaPreviewAdapter(
glide.into(imageView)
}
}
private val differ = AsyncListDiffer(
this,
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
override fun areItemsTheSame(
oldItem: ComposeActivity.QueuedMedia,
newItem: ComposeActivity.QueuedMedia
): Boolean {
return oldItem.localId == newItem.localId
}
override fun areContentsTheSame(
oldItem: ComposeActivity.QueuedMedia,
newItem: ComposeActivity.QueuedMedia
): Boolean {
return oldItem == newItem
}
holder.progressImageView.setOnClickListener {
onMediaClick(item, holder.progressImageView)
}
)
}
inner class PreviewViewHolder(val progressImageView: ProgressImageView) :
RecyclerView.ViewHolder(progressImageView) {
@ -140,9 +130,6 @@ class MediaPreviewAdapter(
layoutParams.setMargins(margin, 0, margin, marginBottom)
progressImageView.layoutParams = layoutParams
progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP
progressImageView.setOnClickListener {
onMediaClick(bindingAdapterPosition, progressImageView)
}
}
}
}

View file

@ -27,7 +27,7 @@ import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeViewModel.QueuedMedia
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.asRequestBody
@ -36,10 +36,10 @@ import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -92,15 +92,16 @@ class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception()
class UploadServerError(val errorMessage: String) : Exception()
@Singleton
class MediaUploader @Inject constructor(
private val context: Context,
@ApplicationContext private val context: Context,
private val mediaUploadApi: MediaUploadApi
) {
private val uploads = mutableMapOf<Int, UploadData>()
private var mostRecentId: Int = 0
private companion object {
private const val TAG = "MediaUploader"
private val uploads = mutableMapOf<Int, UploadData>()
private var mostRecentId: Int = 0
}
fun getNewLocalMediaId(): Int {
return mostRecentId++
@ -150,7 +151,7 @@ class MediaUploader @Inject constructor(
}
}
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): Result<PreparedMedia> = runCatching {
var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri
val mimeType: String?
@ -216,9 +217,8 @@ class MediaUploader @Inject constructor(
Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
}
if (mimeType != null) {
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
when (mimeType.substring(0, mimeType.indexOf('/'))) {
"video" -> {
if (mediaSize > instanceInfo.videoSizeLimit) {
throw FileSizeException(instanceInfo.videoSizeLimit)
@ -246,7 +246,7 @@ class MediaUploader @Inject constructor(
private val contentResolver = context.contentResolver
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
private fun upload(media: QueuedMedia): Flow<UploadEvent> {
return callbackFlow {
var mimeType = contentResolver.getType(media.uri)
@ -264,12 +264,8 @@ class MediaUploader @Inject constructor(
}
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = "%s_%d_%s.%s".format(
context.getString(R.string.app_name),
System.currentTimeMillis(),
randomAlphanumericString(10),
fileExtension
)
val filename =
"${context.getString(R.string.app_name)}_${System.currentTimeMillis()}_${randomAlphanumericString(10)}.$fileExtension"
if (mimeType == null) mimeType = "multipart/form-data"
@ -327,8 +323,4 @@ class MediaUploader @Inject constructor(
return media.type == QueuedMedia.Type.IMAGE &&
(media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit)
}
private companion object {
private const val TAG = "MediaUploader"
}
}

View file

@ -20,8 +20,9 @@ package com.keylesspalace.tusky.components.compose.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.WindowManager
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogAddPollBinding
import com.keylesspalace.tusky.entity.NewPoll
@ -37,10 +38,16 @@ fun showAddPollDialog(
) {
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
val dialog = AlertDialog.Builder(context)
val inset = context.resources.getDimensionPixelSize(R.dimen.dialog_inset)
val dialog = MaterialAlertDialogBuilder(context)
.setIcon(R.drawable.ic_poll_24dp)
.setTitle(R.string.create_poll_title)
.setView(binding.root)
.setBackgroundInsetTop(inset)
.setBackgroundInsetEnd(inset)
.setBackgroundInsetBottom(inset)
.setBackgroundInsetStart(inset)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, null)
.create()
@ -63,9 +70,8 @@ fun showAddPollDialog(
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(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item)
}
binding.pollDurationDropDown.setSimpleItems(durationLabels.toTypedArray())
durations = durations.filter { it in minDuration..maxDuration }
binding.addChoiceButton.setOnClickListener {
@ -79,23 +85,24 @@ fun showAddPollDialog(
val secondsInADay = 60 * 60 * 24
val desiredDuration = poll?.expiresIn ?: secondsInADay
val pollDurationId = durations.indexOfLast {
var selectedDurationIndex = durations.indexOfLast {
it <= desiredDuration
}
binding.pollDurationSpinner.setSelection(pollDurationId)
binding.pollDurationDropDown.setText(durationLabels[selectedDurationIndex], false)
binding.pollDurationDropDown.setOnItemClickListener { _, _, position, _ ->
selectedDurationIndex = position
}
binding.multipleChoicesCheckBox.isChecked = poll?.multiple ?: false
binding.multipleChoicesCheckBox.isChecked = poll?.multiple == true
dialog.setOnShowListener {
val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
button.setOnClickListener {
val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition
onUpdatePoll(
NewPoll(
options = adapter.pollOptions,
expiresIn = durations[selectedPollDurationId],
expiresIn = durations[selectedDurationIndex],
multiple = binding.multipleChoicesCheckBox.isChecked
)
)
@ -106,6 +113,18 @@ fun showAddPollDialog(
dialog.show()
// yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard
dialog.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
binding.pollChoices.post {
val firstItemView = binding.pollChoices.layoutManager?.findViewByPosition(0)
val editText = firstItemView?.findViewById<TextInputEditText>(R.id.optionEditText)
editText?.requestFocus()
editText?.setSelection(editText.length())
}
// make the dialog focusable so the keyboard does not stay behind it
dialog.window?.clearFlags(
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM

View file

@ -15,27 +15,27 @@
package com.keylesspalace.tusky.components.compose.dialog
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.text.InputFilter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.LinearLayout
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
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.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
import com.keylesspalace.tusky.util.getParcelableCompat
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
@ -43,22 +43,35 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
class CaptionDialog : DialogFragment() {
private lateinit var listener: Listener
private val binding by viewBinding(DialogImageDescriptionBinding::bind)
private lateinit var binding: DialogImageDescriptionBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
private var animatable: Animatable? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
val inset = requireContext().resources.getDimensionPixelSize(R.dimen.dialog_inset)
return MaterialAlertDialogBuilder(requireContext())
.setView(createView(savedInstanceState))
.setBackgroundInsetTop(inset)
.setBackgroundInsetEnd(inset)
.setBackgroundInsetBottom(inset)
.setBackgroundInsetStart(inset)
.setPositiveButton(android.R.string.ok) { _, _ ->
listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString())
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.dialog_image_description, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
private fun createView(savedInstanceState: Bundle?): View {
binding = DialogImageDescriptionBinding.inflate(layoutInflater)
val imageView = binding.imageDescriptionView
imageView.maxZoom = 6f
val imageDescriptionText = binding.imageDescriptionText
imageDescriptionText.post {
imageDescriptionText.requestFocus()
imageDescriptionText.setSelection(imageDescriptionText.length())
}
binding.imageDescriptionText.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
@ -71,18 +84,10 @@ class CaptionDialog : DialogFragment() {
binding.imageDescriptionText.setText(it)
}
binding.cancelButton.setOnClickListener {
dismiss()
}
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
binding.okButton.setOnClickListener {
listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString())
dismiss()
}
isCancelable = true
dialog?.setCanceledOnTouchOutside(false) // Dialog is full screen anyway. But without this, taps in navbar while keyboard is up can dismiss the dialog.
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
val previewUri = arguments?.getParcelableCompat<Uri>(PREVIEW_URI_ARG) ?: 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)
@ -97,6 +102,23 @@ class CaptionDialog : DialogFragment() {
resource: Drawable,
transition: Transition<in Drawable>?
) {
if (resource is Animatable) {
resource.callback = object : Drawable.Callback {
override fun invalidateDrawable(who: Drawable) {
imageView.invalidate()
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
imageView.postDelayed(what, `when`)
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
imageView.removeCallbacks(what)
}
}
resource.start()
animatable = resource
}
imageView.setImageDrawable(resource)
}
@ -105,6 +127,7 @@ class CaptionDialog : DialogFragment() {
imageView.hide()
}
})
return binding.root
}
override fun onStart() {
@ -112,7 +135,7 @@ class CaptionDialog : DialogFragment() {
dialog?.apply {
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
LinearLayout.LayoutParams.WRAP_CONTENT
)
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
@ -128,6 +151,12 @@ class CaptionDialog : DialogFragment() {
listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener")
}
override fun onDestroyView() {
super.onDestroyView()
animatable?.stop()
(animatable as? Drawable?)?.callback = null
}
interface Listener {
fun onUpdateDescription(localId: Int, description: String)
}

View file

@ -20,7 +20,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@ -30,6 +29,7 @@ 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.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.databinding.DialogFocusBinding
import com.keylesspalace.tusky.entity.Attachment.Focus
import kotlinx.coroutines.launch
@ -50,10 +50,10 @@ fun <T> T.makeFocusDialog(
.downsample(DownsampleStrategy.CENTER_INSIDE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
p0: GlideException?,
p1: Any?,
p2: Target<Drawable?>,
p3: Boolean
error: GlideException?,
model: Any?,
target: Target<Drawable?>,
isFirstResource: Boolean
): Boolean {
return false
}
@ -68,15 +68,20 @@ fun <T> T.makeFocusDialog(
val width = resource.intrinsicWidth
val height = resource.intrinsicHeight
dialogBinding.focusIndicator.setImageSize(width, height)
val viewWidth = dialogBinding.imageView.width
val viewHeight = dialogBinding.imageView.height
val scaledHeight = (viewWidth.toFloat() / width.toFloat()) * height
dialogBinding.focusIndicator.setImageSize(viewWidth, scaledHeight.toInt())
// We want the dialog to be a little taller than the image, so you can slide your thumb past the image border,
// but if it's *too* much taller that looks weird. See if a threshold has been crossed:
if (width > height) {
val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight()
if (dialogBinding.imageView.height > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight)
if (viewHeight > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(viewWidth, maxHeight)
dialogBinding.imageView.layoutParams = verticalShrinkLayout
dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout
}
@ -93,7 +98,7 @@ fun <T> T.makeFocusDialog(
dialog.dismiss()
}
val dialog = AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setView(dialogBinding.root)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)

View file

@ -14,12 +14,13 @@
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose.view
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
@ -80,19 +81,16 @@ class ComposeScheduleView
}
val scheduled = scheduleDateTimeUtc!!.time
binding.scheduledDateTime.text = String.format(
"%s %s",
dateFormat.format(scheduled),
timeFormat.format(scheduled)
)
@SuppressLint("SetTextI18n")
binding.scheduledDateTime.text = "${dateFormat.format(scheduled)} ${timeFormat.format(scheduled)}"
verifyScheduledTime(scheduled)
}
private fun setEditIcons() {
val icon = ContextCompat.getDrawable(context, R.drawable.ic_create_24dp) ?: return
val icon = AppCompatResources.getDrawable(context, R.drawable.ic_create_24dp) ?: return
val size = binding.scheduledDateTime.lineHeight
icon.setBounds(0, 0, size, size)
binding.scheduledDateTime.setCompoundDrawables(null, null, icon, null)
binding.scheduledDateTime.setCompoundDrawablesRelative(null, null, icon, null)
}
fun setResetOnClickListener(listener: OnClickListener?) {

View file

@ -107,7 +107,7 @@ class FocusIndicatorView
val imageSize = this.imageSize
val focus = this.focus
if (imageSize != null && focus != null) {
if (imageSize != null && focus?.x != null && focus.y != null) {
val x = axisFromFocus(focus.x, imageSize.x, this.width)
val y = axisFromFocus(-focus.y, imageSize.y, this.height)
val circleRadius = getCircleRadius()

View file

@ -16,9 +16,12 @@
package com.keylesspalace.tusky.components.compose.view
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import com.google.android.material.R as materialR
import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter
import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding
@ -27,23 +30,18 @@ import com.keylesspalace.tusky.entity.NewPoll
class PollPreviewView @JvmOverloads constructor(
context: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
defStyleAttr: Int = materialR.attr.materialCardViewOutlinedStyle
) :
LinearLayout(context, attrs, defStyleAttr) {
MaterialCardView(context, attrs, defStyleAttr) {
private val adapter = PreviewPollOptionsAdapter()
private val binding = ViewPollPreviewBinding.inflate(LayoutInflater.from(context), this)
init {
orientation = VERTICAL
setBackgroundResource(R.drawable.card_frame)
val padding = resources.getDimensionPixelSize(R.dimen.poll_preview_padding)
setPadding(padding, padding, padding, padding)
setStrokeColor(ColorStateList.valueOf(MaterialColors.getColor(this, materialR.attr.colorOutline)))
strokeWidth
elevation = 0f
binding.pollPreviewOptions.adapter = adapter
}

View file

@ -24,6 +24,8 @@ import android.graphics.RectF
import android.util.AttributeSet
import androidx.appcompat.content.res.AppCompatResources
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.R as materialR
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.view.MediaPreviewImageView
@ -37,7 +39,7 @@ class ProgressImageView
private val progressRect = RectF()
private val biggerRect = RectF()
private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getColor(R.color.chinwag_green)
color = MaterialColors.getColor(this@ProgressImageView, materialR.attr.colorPrimary)
strokeWidth = Utils.dpToPx(context, 4).toFloat()
style = Paint.Style.STROKE
}
@ -46,13 +48,15 @@ class ProgressImageView
}
private val markBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = context.getColor(R.color.tusky_grey_10)
color = MaterialColors.getColor(this@ProgressImageView, android.R.attr.colorBackground)
}
private val captionDrawable = AppCompatResources.getDrawable(
context,
R.drawable.spellcheck
)!!.apply {
setTint(Color.WHITE)
setTint(
MaterialColors.getColor(this@ProgressImageView, android.R.attr.textColorTertiary)
)
}
private val circleRadius = Utils.dpToPx(context, 14)
private val circleMargin = Utils.dpToPx(context, 14)
@ -68,8 +72,10 @@ class ProgressImageView
}
fun setChecked(checked: Boolean) {
markBgPaint.color =
context.getColor(if (checked) R.color.chinwag_green else R.color.tusky_grey_10)
val backgroundColor = if (checked) materialR.attr.colorPrimary else android.R.attr.colorBackground
val foregroundColor = if (checked) materialR.attr.colorOnPrimary else android.R.attr.textColorTertiary
markBgPaint.color = MaterialColors.getColor(this, backgroundColor)
captionDrawable.setTint(MaterialColors.getColor(this, foregroundColor))
invalidate()
}

View file

@ -38,9 +38,9 @@ class TootButton
init {
if (smallStyle) {
setIconResource(R.drawable.ic_send_24dp)
iconPadding = 0
} else {
setText(R.string.action_send)
iconGravity = ICON_GRAVITY_TEXT_START
}
val padding = resources.getDimensionPixelSize(R.dimen.toot_button_horizontal_padding)
setPadding(padding, 0, padding, 0)

View file

@ -48,13 +48,9 @@ class ConversationAdapter(
onBindViewHolder(holder, position, emptyList())
}
override fun onBindViewHolder(
holder: ConversationViewHolder,
position: Int,
payloads: List<Any>
) {
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int, payloads: List<Any>) {
getItem(position)?.let { conversationViewData ->
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
holder.setupWithConversation(conversationViewData, payloads)
}
}
@ -80,7 +76,7 @@ class ConversationAdapter(
): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
StatusBaseViewHolder.Key.KEY_CREATED
} else {
// If items are different - update the whole view holder
null

View file

@ -126,7 +126,7 @@ data class ConversationStatusEntity(
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
tags = tags.orEmpty(),
application = null,
pinned = false,
muted = muted,
@ -148,7 +148,7 @@ fun TimelineAccount.toEntity() = ConversationAccountEntity(
username = username,
displayName = name,
avatar = avatar,
emojis = emojis.orEmpty()
emojis = emojis
)
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =

View file

@ -24,7 +24,6 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
@ -69,30 +68,37 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
void setupWithConversation(
@NonNull ConversationViewData conversation,
@Nullable Object payloads
@NonNull List<Object> payloads
) {
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
Status status = statusViewData.getStatus();
if (payloads == null) {
if (payloads.isEmpty()) {
TimelineAccount account = status.getAccount();
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
String displayName = account.getDisplayName();
if (displayName == null) {
displayName = "";
}
setDisplayName(displayName, account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
setMetaData(statusViewData, statusDisplayOptions, listener);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
if (attachments.isEmpty()) {
mediaContainer.setVisibility(View.GONE);
} else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
mediaContainer.setVisibility(View.VISIBLE);
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
if (attachments.isEmpty()) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
@ -100,6 +106,8 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE);
}
} else {
mediaContainer.setVisibility(View.VISIBLE);
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreview.setVisibility(View.GONE);
@ -115,11 +123,9 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setAvatars(conversation.getAccounts());
} else {
if (payloads instanceof List) {
for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(statusViewData, statusDisplayOptions, listener);
}
for (Object item : payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(statusViewData, statusDisplayOptions, listener);
}
}
}

View file

@ -15,98 +15,80 @@
package com.keylesspalace.tusky.components.conversation
import android.content.SharedPreferences
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
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.isAnyLoading
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
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 dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class ConversationsFragment :
SFragment(),
SFragment(R.layout.fragment_timeline),
StatusActionListener,
Injectable,
ReselectableFragment,
MenuProvider {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var eventHub: EventHub
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
@Inject
lateinit var preferences: SharedPreferences
private val viewModel: ConversationsViewModel by viewModels()
private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter
private var hideFab = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
private var adapter: ConversationAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled != false,
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
@ -120,11 +102,12 @@ class ConversationsFragment :
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
)
adapter = ConversationAdapter(statusDisplayOptions, this)
val adapter = ConversationAdapter(statusDisplayOptions, this)
this.adapter = adapter
setupRecyclerView()
setupRecyclerView(adapter)
initSwipeToRefresh()
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
@ -135,7 +118,7 @@ class ConversationsFragment :
binding.progressBar.hide()
if (loadState.isAnyLoading()) {
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
eventHub.dispatch(
ConversationsLoadingEvent(
accountManager.activeAccount?.accountId ?: ""
@ -174,7 +157,8 @@ class ConversationsFragment :
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && adapter.itemCount != itemCount) {
val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition()
if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (getView() != null) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
@ -184,53 +168,29 @@ class ConversationsFragment :
}
})
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val composeButton = (activity as ActionButtonActivity).actionButton
if (composeButton != null) {
if (hideFab) {
if (dy > 0 && composeButton.isShown) {
composeButton.hide() // hides the button if we're scrolling down
} else if (dy < 0 && !composeButton.isShown) {
composeButton.show() // shows it if we are scrolling up
}
} else if (!composeButton.isShown) {
composeButton.show()
}
}
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
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))
}
}
}
updateRelativeTimePeriodically(preferences, adapter)
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
onPreferenceChanged(adapter, event.preferenceKey)
}
}
}
}
override fun onDestroyView() {
// Clear the adapter to prevent leaking the View
adapter = null
super.onDestroyView()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_conversations, menu)
menu.findItem(R.id.action_refresh)?.apply {
@ -253,7 +213,8 @@ class ConversationsFragment :
}
}
private fun setupRecyclerView() {
private fun setupRecyclerView(adapter: ConversationAdapter) {
binding.recyclerView.ensureBottomPadding(fab = true)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
@ -268,26 +229,21 @@ class ConversationsFragment :
}
private fun refreshContent() {
adapter.refresh()
adapter?.refresh()
}
private fun initSwipeToRefresh() {
binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
override fun onReblog(reblog: Boolean, position: Int) {
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
// its impossible to reblog private messages
}
override fun onFavourite(favourite: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
}
override fun onBookmark(favourite: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
}
@ -295,7 +251,7 @@ class ConversationsFragment :
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
override fun onMore(view: View, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
@ -319,17 +275,17 @@ class ConversationsFragment :
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewMedia(
attachmentIndex,
AttachmentViewData.list(conversation.lastStatus.status),
AttachmentViewData.list(conversation.lastStatus),
view
)
}
}
override fun onViewThread(position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
}
}
@ -339,13 +295,13 @@ class ConversationsFragment :
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
}
@ -355,7 +311,7 @@ class ConversationsFragment :
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
}
@ -375,13 +331,13 @@ class ConversationsFragment :
}
override fun onReply(position: Int) {
adapter.peek(position)?.let { conversation ->
adapter?.peek(position)?.let { conversation ->
reply(conversation.lastStatus.status)
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.peek(position)?.let { conversation ->
override fun onVoteInPoll(position: Int, choices: List<Int>) {
adapter?.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}
@ -390,7 +346,7 @@ class ConversationsFragment :
}
override fun onReselect() {
if (isAdded) {
if (view != null) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
@ -401,7 +357,7 @@ class ConversationsFragment :
}
private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext())
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
@ -410,13 +366,8 @@ class ConversationsFragment :
.show()
}
private fun onPreferenceChanged(key: String) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
private fun onPreferenceChanged(adapter: ConversationAdapter, key: String) {
when (key) {
PrefKeys.FAB_HIDE -> {
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
}
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled

View file

@ -5,7 +5,6 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
@ -15,19 +14,22 @@ import retrofit2.HttpException
class ConversationsRemoteMediator(
private val api: MastodonApi,
private val db: AppDatabase,
accountManager: AccountManager
private val viewModel: ConversationsViewModel
) : RemoteMediator<Int, ConversationEntity>() {
private var nextKey: String? = null
private var order: Int = 0
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): MediatorResult {
val activeAccount = viewModel.activeAccountFlow.value
if (activeAccount == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
if (loadType == LoadType.PREPEND) {
return MediatorResult.Success(endOfPaginationReached = true)
}

View file

@ -28,29 +28,30 @@ import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.EmptyPagingSource
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@HiltViewModel
class ConversationsViewModel @Inject constructor(
private val timelineCases: TimelineCases,
private val database: AppDatabase,
private val accountManager: AccountManager,
private val api: MastodonApi
private val api: MastodonApi,
accountManager: AccountManager
) : ViewModel() {
val activeAccountFlow = accountManager.activeAccount(viewModelScope)
private val accountId: Long = activeAccountFlow.value!!.id
@OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager(
config = PagingConfig(pageSize = 30),
remoteMediator = ConversationsRemoteMediator(api, database, accountManager),
config = PagingConfig(
pageSize = 30
),
remoteMediator = ConversationsRemoteMediator(api, database, this),
pagingSourceFactory = {
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
EmptyPagingSource()
} else {
database.conversationDao().conversationsForAccount(activeAccount.id)
}
database.conversationDao().conversationsForAccount(accountId)
}
)
.flow
@ -63,7 +64,7 @@ class ConversationsViewModel @Inject constructor(
viewModelScope.launch {
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
favourited = favourite
)
@ -78,7 +79,7 @@ class ConversationsViewModel @Inject constructor(
viewModelScope.launch {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
bookmarked = bookmark
)
@ -98,7 +99,7 @@ class ConversationsViewModel @Inject constructor(
)
.fold({ poll ->
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
poll = poll
)
@ -112,7 +113,7 @@ class ConversationsViewModel @Inject constructor(
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
expanded = expanded
)
saveConversationToDb(newConversation)
@ -122,7 +123,7 @@ class ConversationsViewModel @Inject constructor(
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
collapsed = collapsed
)
saveConversationToDb(newConversation)
@ -132,7 +133,7 @@ class ConversationsViewModel @Inject constructor(
fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
showingHiddenContent = showing
)
saveConversationToDb(newConversation)
@ -146,7 +147,7 @@ class ConversationsViewModel @Inject constructor(
database.conversationDao().delete(
id = conversation.id,
accountId = accountManager.activeAccount!!.id
accountId = accountId
)
} catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e)
@ -163,7 +164,7 @@ class ConversationsViewModel @Inject constructor(
)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
muted = !conversation.lastStatus.status.muted
)

View file

@ -4,14 +4,10 @@ import android.os.Bundle
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@AndroidEntryPoint
class DomainBlocksActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -30,6 +26,4 @@ class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
.replace(R.id.fragment_container, DomainBlocksFragment())
.commit()
}
override fun androidInjector() = androidInjector
}

View file

@ -12,28 +12,27 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.ensureBottomPadding
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 javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@AndroidEntryPoint
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks) {
private val binding by viewBinding(FragmentDomainBlocksBinding::bind)
private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory }
private val viewModel: DomainBlocksViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = DomainBlocksAdapter(viewModel::unblock)
binding.recyclerView.ensureBottomPadding()
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.addItemDecoration(
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
@ -47,7 +46,7 @@ class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectab
}
}
lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.domainPager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}

View file

@ -39,7 +39,10 @@ class DomainBlocksRepository @Inject constructor(
@OptIn(ExperimentalPagingApi::class)
val domainPager = Pager(
config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE),
config = PagingConfig(
pageSize = PAGE_SIZE,
initialLoadSize = PAGE_SIZE
),
remoteMediator = DomainBlocksRemoteMediator(api, this),
pagingSourceFactory = factory
).flow

View file

@ -8,12 +8,14 @@ import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.R
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
@HiltViewModel
class DomainBlocksViewModel @Inject constructor(
private val repo: DomainBlocksRepository
) : ViewModel() {

View file

@ -23,12 +23,13 @@ import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.entity.DraftAttachment
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.copyToFile
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
@ -43,7 +44,7 @@ import okio.buffer
import okio.sink
class DraftHelper @Inject constructor(
val context: Context,
@ApplicationContext val context: Context,
private val okHttpClient: OkHttpClient,
db: AppDatabase
) {
@ -177,7 +178,7 @@ class DraftHelper @Inject constructor(
map.getExtensionFromMimeType(mimeType)
}
val filename = String.format("Tusky_Draft_Media_%s_%d.%s", timeStamp, index, fileExtension)
val filename = "Tusky_Draft_Media_${timeStamp}_$index.$fileExtension"
val file = File(folder, filename)
if (scheme == "https") {

View file

@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.entity.DraftAttachment
import com.keylesspalace.tusky.view.MediaPreviewImageView
class DraftMediaAdapter(

View file

@ -32,25 +32,24 @@ import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.visible
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class DraftsActivity : BaseActivity(), DraftActionListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var draftsAlert: DraftsAlert
private val viewModel: DraftsViewModel by viewModels { viewModelFactory }
private val viewModel: DraftsViewModel by viewModels()
private lateinit var binding: ActivityDraftsBinding
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
@ -68,6 +67,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
setDisplayShowHomeEnabled(true)
}
binding.draftsRecyclerView.ensureBottomPadding()
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_drafts)
val adapter = DraftsAdapter(this)

View file

@ -22,7 +22,7 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemDraftBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show

View file

@ -23,12 +23,14 @@ import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.entity.DraftEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class DraftsViewModel @Inject constructor(
val database: AppDatabase,
val accountManager: AccountManager,
@ -37,7 +39,9 @@ class DraftsViewModel @Inject constructor(
) : ViewModel() {
val drafts = Pager(
config = PagingConfig(pageSize = 20),
config = PagingConfig(
pageSize = 20
),
pagingSourceFactory = {
database.draftDao().draftsPagingSource(
accountManager.activeAccount?.id!!

View file

@ -1,38 +1,54 @@
/* Copyright 2024 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.filters
import android.content.Context
import android.content.DialogInterface.BUTTON_POSITIVE
import android.os.Bundle
import android.view.View
import android.view.WindowManager
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.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.size
import androidx.core.view.updatePadding
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.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
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.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getParcelableExtraCompat
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import java.util.Date
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class EditFilterActivity : BaseActivity() {
@Inject
lateinit var api: MastodonApi
@ -40,20 +56,17 @@ class EditFilterActivity : BaseActivity() {
@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 val viewModel: EditFilterViewModel by viewModels()
private lateinit var filter: Filter
private var originalFilter: Filter? = null
private lateinit var contextSwitches: Map<SwitchMaterial, Filter.Kind>
private lateinit var contextSwitches: Map<MaterialSwitch, Filter.Kind>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java)
originalFilter = intent.getParcelableExtraCompat(FILTER_TO_EDIT)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
binding.apply {
contextSwitches = mapOf(
@ -81,6 +94,12 @@ class EditFilterActivity : BaseActivity() {
}
)
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets ->
val systemBarsInsets = insets.getInsets(systemBars())
scrollView.updatePadding(bottom = systemBarsInsets.bottom)
insets.inset(0, 0, 0, systemBarsInsets.bottom)
}
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener {
@ -114,30 +133,20 @@ class EditFilterActivity : BaseActivity() {
}
)
}
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)
}
binding.filterDurationDropDown.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
viewModel.setDuration(
if (originalFilter?.expiresAt == null) {
position
} else {
position - 1
}
)
}
validateSaveButton()
if (originalFilter == null) {
binding.filterActionWarn.isChecked = true
initializeDurationDropDown(false)
} else {
loadFilter()
}
@ -179,10 +188,17 @@ class EditFilterActivity : BaseActivity() {
// 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)
initializeDurationDropDown(withNoChange = filter.expiresAt != null)
}
private fun initializeDurationDropDown(withNoChange: Boolean) {
val durationNames = if (withNoChange) {
arrayOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
} else {
resources.getStringArray(R.array.filter_duration_names)
}
binding.filterDurationDropDown.setSimpleItems(durationNames)
binding.filterDurationDropDown.setText(durationNames[0], false)
}
private fun updateKeywords(newKeywords: List<FilterKeyword>) {
@ -223,7 +239,7 @@ class EditFilterActivity : BaseActivity() {
private fun showAddKeywordDialog() {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.filter_keyword_addition_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
@ -237,6 +253,12 @@ class EditFilterActivity : BaseActivity() {
}
.setNegativeButton(android.R.string.cancel, null)
.show()
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val editText = binding.phraseEditText
editText.requestFocus()
editText.setSelection(editText.length())
}
private fun showEditKeywordDialog(keyword: FilterKeyword) {
@ -244,7 +266,7 @@ class EditFilterActivity : BaseActivity() {
binding.phraseEditText.setText(keyword.keyword)
binding.phraseWholeWord.isChecked = keyword.wholeWord
AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.filter_edit_keyword_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
@ -258,6 +280,12 @@ class EditFilterActivity : BaseActivity() {
}
.setNegativeButton(android.R.string.cancel, null)
.show()
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val editText = binding.phraseEditText
editText.requestFocus()
editText.setSelection(editText.length())
}
private fun validateSaveButton() {
@ -278,7 +306,7 @@ class EditFilterActivity : BaseActivity() {
} else {
Snackbar.make(
binding.root,
"Error saving filter '${viewModel.title.value}'",
getString(R.string.error_deleting_filter, viewModel.title.value),
Snackbar.LENGTH_SHORT
).show()
}
@ -301,7 +329,7 @@ class EditFilterActivity : BaseActivity() {
{
Snackbar.make(
binding.root,
"Error deleting filter '${filter.title}'",
getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
@ -309,7 +337,7 @@ class EditFilterActivity : BaseActivity() {
} else {
Snackbar.make(
binding.root,
"Error deleting filter '${filter.title}'",
getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
@ -321,19 +349,5 @@ class EditFilterActivity : BaseActivity() {
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)
}
}
}
}

View file

@ -1,21 +1,38 @@
/* Copyright 2024 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.filters
import android.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.R
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterKeyword
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
@HiltViewModel
class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel() {
private var originalFilter: Filter? = null
private val _title = MutableStateFlow("")
@ -111,12 +128,12 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
durationIndex: Int,
context: Context
): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
val expiration = getExpirationForDurationIndex(durationIndex, context)
api.createFilter(
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds
expiresIn = expiration
).fold(
{ newFilter ->
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
@ -132,7 +149,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
return (
throwable.isHttpNotFound() &&
// Endpoint not found, fall back to v1 api
createFilterV1(contexts, expiresInSeconds)
createFilterV1(contexts, expiration)
)
}
)
@ -146,13 +163,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
durationIndex: Int,
context: Context
): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
val expiration = getExpirationForDurationIndex(durationIndex, context)
api.updateFilter(
id = originalFilter.id,
title = title,
context = contexts,
filterAction = action,
expiresInSeconds = expiresInSeconds
expires = expiration
).fold(
{
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
@ -172,7 +189,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
{ throwable ->
if (throwable.isHttpNotFound()) {
// Endpoint not found, fall back to v1 api
if (updateFilterV1(contexts, expiresInSeconds)) {
if (updateFilterV1(contexts, expiration)) {
return true
}
}
@ -181,13 +198,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
)
}
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
private suspend fun createFilterV1(context: List<String>, expiration: FilterExpiration?): Boolean {
return _keywords.value.map { keyword ->
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiration)
}.none { it.isFailure }
}
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
private suspend fun updateFilterV1(context: List<String>, expiration: FilterExpiration?): Boolean {
val results = _keywords.value.map { keyword ->
if (originalFilter == null) {
api.createFilterV1(
@ -195,7 +212,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds
expiresIn = expiration
)
} else {
api.updateFilterV1(
@ -204,7 +221,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
context = context,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds
expiresIn = expiration
)
}
}
@ -212,4 +229,18 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
return results.none { it.isFailure }
}
companion object {
// Mastodon *stores* the absolute date in the filter,
// but create/edit take a number of seconds (relative to the time the operation is posted)
private fun getExpirationForDurationIndex(index: Int, context: Context): FilterExpiration? {
return when (index) {
-1 -> FilterExpiration.unchanged
0 -> FilterExpiration.never
else -> FilterExpiration.seconds(
context.resources.getIntArray(R.array.filter_duration_values)[index]
)
}
}
}
}

View file

@ -0,0 +1,37 @@
/* Copyright 2024 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.filters
import kotlin.jvm.JvmInline
/**
* Custom class to have typesafety for filter expirations.
* Retrofit will call toString when sending this class as part of a form-urlencoded body.
*/
@JvmInline
value class FilterExpiration private constructor(val seconds: Int) {
override fun toString(): String {
return if (seconds < 0) "" else seconds.toString()
}
companion object {
val unchanged: FilterExpiration? = null
val never: FilterExpiration = FilterExpiration(-1)
fun seconds(seconds: Int): FilterExpiration = FilterExpiration(seconds)
}
}

View file

@ -18,14 +18,14 @@
package com.keylesspalace.tusky.components.filters
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.await
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(
this
)
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
.setCancelable(true)
.create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String): Int {
return MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
.setCancelable(true)
.create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)
}

View file

@ -3,27 +3,36 @@ package com.keylesspalace.tusky.components.filters
import android.content.DialogInterface.BUTTON_POSITIVE
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
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.ensureBottomMargin
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.launchAndRepeatOnLifecycle
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import javax.inject.Inject
import com.keylesspalace.tusky.util.withSlideInAnimation
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class FiltersActivity : BaseActivity(), FiltersListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityFiltersBinding::inflate)
private val viewModel: FiltersViewModel by viewModels { viewModelFactory }
private val viewModel: FiltersViewModel by viewModels()
private val editFilterLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
// refresh the filters upon returning from EditFilterActivity
reloadFilters()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -36,24 +45,26 @@ class FiltersActivity : BaseActivity(), FiltersListener {
setDisplayShowHomeEnabled(true)
}
binding.filtersList.ensureBottomPadding(fab = true)
binding.addFilterButton.ensureBottomMargin()
binding.addFilterButton.setOnClickListener {
launchEditFilterActivity()
}
binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
binding.swipeRefreshLayout.setOnRefreshListener { reloadFilters() }
setTitle(R.string.pref_title_timeline_filters)
}
override fun onResume() {
super.onResume()
loadFilters()
binding.filtersList.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
observeViewModel()
}
private fun observeViewModel() {
lifecycleScope.launch {
launchAndRepeatOnLifecycle {
viewModel.state.collect { state ->
binding.progressBar.visible(
state.loadingState == FiltersViewModel.LoadingState.LOADING
@ -70,7 +81,7 @@ class FiltersActivity : BaseActivity(), FiltersListener {
R.drawable.errorphant_offline,
R.string.error_network
) {
loadFilters()
reloadFilters()
}
binding.messageView.show()
}
@ -79,7 +90,7 @@ class FiltersActivity : BaseActivity(), FiltersListener {
R.drawable.errorphant_error,
R.string.error_generic
) {
loadFilters()
reloadFilters()
}
binding.messageView.show()
}
@ -101,8 +112,8 @@ class FiltersActivity : BaseActivity(), FiltersListener {
}
}
private fun loadFilters() {
viewModel.load()
private fun reloadFilters() {
viewModel.reload()
}
private fun launchEditFilterActivity(filter: Filter? = null) {
@ -110,8 +121,8 @@ class FiltersActivity : BaseActivity(), FiltersListener {
if (filter != null) {
putExtra(EditFilterActivity.FILTER_TO_EDIT, filter)
}
}
startActivityWithSlideInAnimation(intent)
}.withSlideInAnimation()
editFilterLauncher.launch(intent)
}
override fun deleteFilter(filter: Filter) {

View file

@ -1,21 +1,28 @@
package com.keylesspalace.tusky.components.filters
import android.util.Log
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.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class FiltersViewModel @Inject constructor(
private val api: MastodonApi,
private val eventHub: EventHub
@ -34,76 +41,93 @@ class FiltersViewModel @Inject constructor(
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
val state: StateFlow<State> = _state.asStateFlow()
fun load() {
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
private val loadTrigger = MutableStateFlow(0)
init {
viewModelScope.launch {
observeLoad()
}
}
private suspend fun observeLoad() {
loadTrigger.collectLatest {
_state.update { it.copy(loadingState = LoadingState.LOADING) }
api.getFilters().fold(
{ filters ->
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
_state.value = State(filters, LoadingState.LOADED)
},
{ throwable ->
if (throwable.isHttpNotFound()) {
Log.i(TAG, "failed loading filters v2, falling back to v1", throwable)
api.getFiltersV1().fold(
{ filters ->
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
_state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
},
{ _ ->
// TODO log errors (also below)
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
{ t ->
Log.w(TAG, "failed loading filters v1", t)
_state.value = State(emptyList(), 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)
Log.w(TAG, "failed loading filters v2", throwable)
_state.update { it.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
},
fun reload() {
loadTrigger.update { it + 1 }
}
suspend fun deleteFilter(filter: Filter, parent: View) {
// First wait for a pending loading operation to complete
_state.first { it.loadingState > LoadingState.LOADING }
api.deleteFilter(filter.id).fold(
{
_state.update { currentState ->
State(
currentState.filters.filter { it.id != filter.id },
LoadingState.LOADED
)
for (context in filter.context) {
eventHub.dispatch(PreferenceChangedEvent(context))
}
},
{ throwable ->
if (throwable.isHttpNotFound()) {
api.deleteFilterV1(filter.id).fold(
{
this@FiltersViewModel._state.value = State(
this@FiltersViewModel._state.value.filters.filter {
it.id != filter.id
},
}
eventHub.dispatch(FilterUpdatedEvent(filter.context))
},
{ throwable ->
if (throwable.isHttpNotFound()) {
api.deleteFilterV1(filter.id).fold(
{
_state.update { currentState ->
State(
currentState.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()
}
},
{
Snackbar.make(
parent,
parent.context.getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
)
} else {
Snackbar.make(
parent,
parent.context.getString(R.string.error_deleting_filter, filter.title),
Snackbar.LENGTH_SHORT
).show()
}
)
}
}
)
}
companion object {
private const val TAG = "FiltersViewModel"
}
}

View file

@ -1,53 +1,48 @@
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.StatusListActivity
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.copyToClipboard
import com.keylesspalace.tusky.util.ensureBottomMargin
import com.keylesspalace.tusky.util.ensureBottomPadding
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 com.keylesspalace.tusky.view.showHashtagPickerDialog
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class FollowedTagsActivity :
BaseActivity(),
HashtagActionListener,
ComposeAutoCompleteAdapter.AutocompletionProvider {
@Inject
lateinit var api: MastodonApi
HashtagActionListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
lateinit var api: MastodonApi
@Inject
lateinit var sharedPreferences: SharedPreferences
private val binding by viewBinding(ActivityFollowedTagsBinding::inflate)
private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory }
private val viewModel: FollowedTagsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -61,9 +56,11 @@ class FollowedTagsActivity :
setDisplayShowHomeEnabled(true)
}
binding.fab.ensureBottomMargin()
binding.followedTagsView.ensureBottomPadding(fab = true)
binding.fab.setOnClickListener {
val dialog: DialogFragment = FollowTagDialog.newInstance()
dialog.show(supportFragmentManager, "dialog")
showDialog()
}
setupAdapter().let { adapter ->
@ -85,19 +82,6 @@ class FollowedTagsActivity :
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 {
@ -123,7 +107,7 @@ class FollowedTagsActivity :
private fun follow(tagName: String, position: Int = -1) {
lifecycleScope.launch {
api.followTag(tagName).fold(
val snackbarText = api.followTag(tagName).fold(
{
if (position == -1) {
viewModel.tags.add(it)
@ -131,17 +115,20 @@ class FollowedTagsActivity :
viewModel.tags.add(position, it)
}
viewModel.currentSource?.invalidate()
getString(R.string.follow_hashtag_success, tagName)
},
{
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(R.string.error_following_hashtag_format, tagName),
Snackbar.LENGTH_SHORT
)
.show()
{ t ->
Log.w(TAG, "failed to follow hashtag $tagName", t)
getString(R.string.error_following_hashtag_format, tagName)
}
)
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
snackbarText,
Snackbar.LENGTH_SHORT
)
.show()
}
}
@ -178,41 +165,24 @@ class FollowedTagsActivity :
}
}
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
return viewModel.searchAutocompleteSuggestions(token)
override fun viewTag(tagName: String) {
startActivity(StatusListActivity.newHashtagIntent(this, tagName))
}
override fun copyTagName(tagName: String) {
copyToClipboard(
"#$tagName",
getString(R.string.confirmation_hashtag_copied, tagName),
)
}
private fun showDialog() {
showHashtagPickerDialog(api, R.string.dialog_follow_hashtag_title) { hashtag ->
follow(hashtag)
}
}
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()
}
}
}

View file

@ -27,7 +27,17 @@ class FollowedTagsAdapter(
position: Int
) {
viewModel.tags[position].let { tag ->
holder.itemView.findViewById<TextView>(R.id.followed_tag).text = tag.name
holder.itemView.findViewById<TextView>(R.id.followed_tag).apply {
text = tag.name
setOnClickListener {
actionListener.viewTag(tag.name)
}
setOnLongClickListener {
actionListener.copyTagName(tag.name)
true
}
}
holder.itemView.findViewById<ImageButton>(
R.id.followed_tag_unfollow
).setOnClickListener {

View file

@ -1,31 +1,29 @@
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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.runBlocking
@HiltViewModel
class FollowedTagsViewModel @Inject constructor(
private val api: MastodonApi
) : ViewModel(), Injectable {
val api: MastodonApi
) : ViewModel() {
val tags: MutableList<HashTag> = mutableListOf()
var nextKey: String? = null
var currentSource: FollowedTagsPagingSource? = null
@OptIn(ExperimentalPagingApi::class)
val pager = Pager(
config = PagingConfig(pageSize = 100),
config = PagingConfig(
pageSize = 100
),
remoteMediator = FollowedTagsRemoteMediator(api, this),
pagingSourceFactory = {
FollowedTagsPagingSource(
@ -36,24 +34,6 @@ class FollowedTagsViewModel @Inject constructor(
}
).flow.cachedIn(viewModelScope)
fun searchAutocompleteSuggestions(
token: String
): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
return runBlocking {
api.search(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"
}

View file

@ -17,6 +17,7 @@ package com.keylesspalace.tusky.components.instanceinfo
import android.util.Log
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import at.connyduck.calladapter.networkresult.getOrThrow
import at.connyduck.calladapter.networkresult.map
@ -24,8 +25,8 @@ import at.connyduck.calladapter.networkresult.onSuccess
import at.connyduck.calladapter.networkresult.recoverCatching
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.db.entity.EmojisEntity
import com.keylesspalace.tusky.db.entity.InstanceInfoEntity
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance
@ -34,13 +35,11 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Singleton
class InstanceInfoRepository @Inject constructor(
private val api: MastodonApi,
db: AppDatabase,
@ -52,9 +51,6 @@ class InstanceInfoRepository @Inject constructor(
private val instanceName
get() = accountManager.activeAccount!!.domain
/** In-memory cache for instance data, per instance domain. */
private var instanceInfoCache = ConcurrentHashMap<String, InstanceInfo>()
fun precache() {
// We are avoiding some duplicate work but we are not trying too hard.
// We might request it multiple times in parallel which is not a big problem.
@ -65,9 +61,11 @@ class InstanceInfoRepository @Inject constructor(
// - caching default value (we want to rather re-fetch if it fails)
if (instanceInfoCache[instanceName] == null) {
externalScope.launch {
fetchAndPersistInstanceInfo().onSuccess { fetched ->
instanceInfoCache[fetched.instance] = fetched.toInfoOrDefault()
}
fetchAndPersistInstanceInfo().fold({ fetched ->
instanceInfoCache[instanceName] = fetched.toInfoOrDefault()
}, { e ->
Log.w(TAG, "failed to precache instance info", e)
})
}
}
}
@ -107,6 +105,10 @@ class InstanceInfoRepository @Inject constructor(
}
}.toInfoOrDefault()
suspend fun saveFilterV2Support(filterV2Supported: Boolean) = dao.setFilterV2Support(instanceName, filterV2Supported)
suspend fun isFilterV2Supported(): Boolean = dao.getFilterV2Support(instanceName)
private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult<InstanceInfoEntity> =
fetchRemoteInstanceInfo()
.onSuccess { instanceInfoEntity ->
@ -168,7 +170,7 @@ class InstanceInfoRepository @Inject constructor(
?: DEFAULT_IMAGE_MATRIX_LIMIT,
maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments
?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields,
maxFields = this.configuration?.accounts?.maxProfileFields ?: this.pleroma?.metadata?.fieldLimits?.maxFields,
maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength,
maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength,
translationEnabled = this.configuration?.translation?.enabled
@ -205,6 +207,9 @@ class InstanceInfoRepository @Inject constructor(
companion object {
private const val TAG = "InstanceInfoRepo"
/** In-memory cache for instance data, per instance domain. */
private var instanceInfoCache = ConcurrentHashMap<String, InstanceInfo>()
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 50

View file

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.login
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.text.method.LinkMovementMethod
@ -25,39 +24,41 @@ import android.util.Log
import android.view.Menu
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.bumptech.glide.Glide
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.openLinkInCustomTab
import com.keylesspalace.tusky.util.rickRoll
import com.keylesspalace.tusky.util.setOnWindowInsetsChangeListener
import com.keylesspalace.tusky.util.shouldRickRoll
import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions
import com.keylesspalace.tusky.util.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
/** Main login page, the first thing that users see. Has prompt for instance and login button. */
class LoginActivity : BaseActivity(), Injectable {
@AndroidEntryPoint
class LoginActivity : BaseActivity() {
@Inject
lateinit var mastodonApi: MastodonApi
private val binding by viewBinding(ActivityLoginBinding::inflate)
private lateinit var preferences: SharedPreferences
private val oauthRedirectUri: String
get() {
val scheme = getString(R.string.oauth_scheme)
@ -67,9 +68,7 @@ class LoginActivity : BaseActivity(), Injectable {
private val doWebViewAuth = registerForActivityResult(OauthLogin()) { result ->
when (result) {
is LoginResult.Ok -> lifecycleScope.launch {
fetchOauthToken(result.code)
}
is LoginResult.Ok -> fetchOauthToken(result.code)
is LoginResult.Err -> displayError(result.errorMessage)
is LoginResult.Cancel -> setLoading(false)
}
@ -80,19 +79,19 @@ class LoginActivity : BaseActivity(), Injectable {
setContentView(binding.root)
binding.loginScrollView.setOnWindowInsetsChangeListener { windowInsets ->
val insets = windowInsets.getInsets(systemBars() or ime())
binding.loginScrollView.updatePadding(bottom = insets.bottom)
}
if (savedInstanceState == null &&
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
!isAdditionalLogin() && !isAccountMigration()
!isAdditionalLogin()
) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if (isAccountMigration()) {
binding.domainEditText.setText(accountManager.activeAccount!!.domain)
binding.domainEditText.isEnabled = false
}
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
@ -100,16 +99,11 @@ class LoginActivity : BaseActivity(), Injectable {
.into(binding.loginLogo)
}
preferences = getSharedPreferences(
getString(R.string.preferences_file_key),
Context.MODE_PRIVATE
)
binding.loginButton.setOnClickListener { onLoginClick(true) }
binding.registerButton.setOnClickListener { onRegisterClick() }
binding.whatsAnInstanceTextView.setOnClickListener {
val dialog = AlertDialog.Builder(this)
val dialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null)
.show()
@ -118,7 +112,7 @@ class LoginActivity : BaseActivity(), Injectable {
}
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration())
supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin())
supportActionBar?.setDisplayShowTitleEnabled(false)
}
@ -184,11 +178,7 @@ class LoginActivity : BaseActivity(), Injectable {
getString(R.string.tusky_website)
).fold(
{ credentials ->
// Before we open browser page we save the data.
// Even if we don't open other apps user may go to password manager or somewhere else
// and we will need to pick up the process where we left off.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
// Save credentials so we can access them after we opened another activity for auth.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
@ -216,17 +206,15 @@ class LoginActivity : BaseActivity(), Injectable {
) {
// To authorize this app and log in it's necessary to redirect to the domain given,
// login there, and the server will redirect back to the app with its response.
val uri = HttpUrl.Builder()
val uri = Uri.Builder()
.scheme("https")
.host(domain)
.addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE)
.addQueryParameter("client_id", clientId)
.addQueryParameter("redirect_uri", oauthRedirectUri)
.addQueryParameter("response_type", "code")
.addQueryParameter("scope", OAUTH_SCOPES)
.authority(domain)
.path(MastodonApi.ENDPOINT_AUTHORIZE)
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", oauthRedirectUri)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", OAUTH_SCOPES)
.build()
.toString()
.toUri()
if (openInWebView) {
doWebViewAuth.launch(LoginData(domain, uri, oauthRedirectUri.toUri()))
@ -247,15 +235,8 @@ class LoginActivity : BaseActivity(), Injectable {
val code = uri.getQueryParameter("code")
val error = uri.getQueryParameter("error")
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
lifecycleScope.launch {
fetchOauthToken(code)
}
if (code != null) {
fetchOauthToken(code)
} else {
displayError(error)
}
@ -275,37 +256,39 @@ class LoginActivity : BaseActivity(), Injectable {
getString(R.string.error_authorization_unknown)
} else {
// Use error returned by the server or fall back to the generic message
Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
Log.e(TAG, getString(R.string.error_authorization_denied) + " " + error)
error.ifBlank { getString(R.string.error_authorization_denied) }
}
}
private suspend fun fetchOauthToken(code: String) {
private fun fetchOauthToken(code: String) {
setLoading(true)
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
setLoading(true)
mastodonApi.fetchOAuthToken(
domain,
clientId,
clientSecret,
oauthRedirectUri,
code,
"authorization_code"
).fold(
{ accessToken ->
fetchAccountDetails(accessToken, domain, clientId, clientSecret)
},
{ e ->
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e)
}
)
lifecycleScope.launch {
mastodonApi.fetchOAuthToken(
domain,
clientId,
clientSecret,
oauthRedirectUri,
code,
"authorization_code"
).fold(
{ accessToken ->
fetchAccountDetails(accessToken, domain, clientId, clientSecret)
},
{ e ->
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e)
}
)
}
}
private suspend fun fetchAccountDetails(
@ -326,15 +309,10 @@ class LoginActivity : BaseActivity(), Injectable {
oauthScopes = OAUTH_SCOPES,
newAccount = newAccount
)
finishAffinity()
val intent = Intent(this, MainActivity::class.java)
intent.putExtra(MainActivity.OPEN_WITH_EXPLODE_ANIMATION, true)
startActivity(intent)
finishAffinity()
if (!supportsOverridingActivityTransitions()) {
@Suppress("DEPRECATION")
overridePendingTransition(R.anim.explode, R.anim.activity_open_exit)
}
}, { e ->
setLoading(false)
binding.domainTextInputLayout.error =
@ -358,10 +336,6 @@ class LoginActivity : BaseActivity(), Injectable {
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN
}
private fun isAccountMigration(): Boolean {
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow push"
@ -373,9 +347,6 @@ 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
@JvmStatic
fun getIntent(context: Context, mode: Int): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)

View file

@ -32,20 +32,22 @@ import android.webkit.WebView
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.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginWebviewBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.getParcelableExtraCompat
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ -62,9 +64,8 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
return if (resultCode == Activity.RESULT_CANCELED) {
LoginResult.Cancel
} else {
intent?.let {
IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java)
} ?: LoginResult.Err("failed parsing LoginWebViewActivity result")
intent?.getParcelableExtraCompat(RESULT_EXTRA)
?: LoginResult.Err("failed parsing LoginWebViewActivity result")
}
}
@ -73,7 +74,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
private const val DATA_EXTRA = "data"
fun parseData(intent: Intent): LoginData {
return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!!
return intent.getParcelableExtraCompat(DATA_EXTRA)!!
}
fun makeResultIntent(result: LoginResult): Intent {
@ -103,13 +104,11 @@ sealed interface LoginResult : Parcelable {
}
/** Activity to do Oauth process using WebView. */
class LoginWebViewActivity : BaseActivity(), Injectable {
@AndroidEntryPoint
class LoginWebViewActivity : BaseActivity() {
private val binding by viewBinding(ActivityLoginWebviewBinding::inflate)
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: LoginWebViewViewModel by viewModels { viewModelFactory }
private val viewModel: LoginWebViewViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -128,10 +127,15 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
setTitle(R.string.title_login)
ViewCompat.setOnApplyWindowInsetsListener(binding.loginWebView) { _, insets ->
val bottomInsets = insets.getInsets(systemBars() or ime()).bottom
binding.root.updatePadding(bottom = bottomInsets)
WindowInsetsCompat.CONSUMED
}
val webView = binding.loginWebView
webView.settings.allowContentAccess = false
webView.settings.allowFileAccess = false
webView.settings.databaseEnabled = false
webView.settings.displayZoomControls = false
webView.settings.javaScriptCanOpenWindowsAutomatically = false
// JavaScript needs to be enabled because otherwise 2FA does not work in some instances
@ -201,7 +205,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
viewModel.instanceRules.collect { instanceRules ->
binding.loginRules.visible(instanceRules.isNotEmpty())
binding.loginRules.setOnClickListener {
AlertDialog.Builder(this@LoginWebViewActivity)
MaterialAlertDialogBuilder(this@LoginWebViewActivity)
.setTitle(getString(R.string.instance_rule_title, data.domain))
.setMessage(
instanceRules.joinToString(separator = "\n\n") { "$it" }

View file

@ -21,11 +21,13 @@ import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class LoginWebViewViewModel @Inject constructor(
private val api: MastodonApi
) : ViewModel() {
@ -49,11 +51,11 @@ class LoginWebViewViewModel @Inject constructor(
{ instance ->
_instanceRules.value = instance.rules.map { rule -> rule.text }
},
{ throwable ->
{ throwable2 ->
Log.w(
"LoginWebViewViewModel",
"failed to load instance info",
throwable
throwable2
)
}
)

View file

@ -0,0 +1,91 @@
/* Copyright 2024 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.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 FollowViewHolder(
private val binding: ItemFollowBinding,
private val listener: AccountActionListener,
private val linkListener: LinkListener
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isNotEmpty()) {
return
}
val context = itemView.context
val account = viewData.account
val messageTemplate =
context.getString(if (viewData.type == Notification.Type.SignUp) R.string.notification_sign_up_format else R.string.notification_follow_format)
val wrappedDisplayName = account.name.unicodeWrap()
binding.notificationText.text = messageTemplate.format(wrappedDisplayName)
.emojify(account.emojis, binding.notificationText, statusDisplayOptions.animateEmojis)
binding.notificationUsername.text = context.getString(R.string.post_username_format, viewData.account.username)
val emojifiedDisplayName = wrappedDisplayName.emojify(
account.emojis,
binding.notificationDisplayName,
statusDisplayOptions.animateEmojis
)
binding.notificationDisplayName.text = emojifiedDisplayName
if (account.note.isEmpty()) {
binding.accountNote.hide()
} else {
binding.accountNote.show()
val emojifiedNote = account.note.parseAsMastodonHtml()
.emojify(account.emojis, binding.accountNote, statusDisplayOptions.animateEmojis)
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
}
val avatarRadius = context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
loadAvatar(
account.avatar,
binding.notificationAvatar,
avatarRadius,
statusDisplayOptions.animateAvatars
)
binding.avatarBadge.visible(statusDisplayOptions.showBotOverlay && account.bot)
itemView.setOnClickListener { listener.onViewAccount(account.id) }
binding.accountNote.setOnClickListener { listener.onViewAccount(account.id) }
}
}

View file

@ -0,0 +1,47 @@
/* Copyright 2025 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import android.content.Intent
import androidx.core.net.toUri
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
class ModerationWarningViewHolder(
private val binding: ItemModerationWarningNotificationBinding,
private val instanceDomain: String
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isNotEmpty()) {
return
}
val warning = viewData.moderationWarning!!
binding.moderationWarningDescription.setText(warning.action.text)
binding.root.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, "https://$instanceDomain/disputes/strikes/${warning.id}".toUri())
binding.root.context.startActivity(intent)
}
}
}

View file

@ -1,866 +0,0 @@
/* Copyright 2018 Jeremiasz Nelz <remi6397(a)gmail.com>
* 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.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;
import android.app.NotificationChannel;
import android.app.NotificationChannelGroup;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
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.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;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.FutureTarget;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
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.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
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;
public class NotificationHelper {
/** 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;
private static final String TAG = "NotificationHelper";
public static final String REPLY_ACTION = "REPLY_ACTION";
public static final String KEY_REPLY = "KEY_REPLY";
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
public static final String KEY_SENDER_ACCOUNT_IDENTIFIER = "KEY_SENDER_ACCOUNT_IDENTIFIER";
public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME";
public static final String KEY_SERVER_NOTIFICATION_ID = "KEY_SERVER_NOTIFICATION_ID";
public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID";
public static final String KEY_VISIBILITY = "KEY_VISIBILITY";
public static final String KEY_SPOILER = "KEY_SPOILER";
public static final String KEY_MENTIONS = "KEY_MENTIONS";
/**
* notification channels used on Android O+
**/
public static final String CHANNEL_MENTION = "CHANNEL_MENTION";
public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
public static final String CHANNEL_FOLLOW_REQUEST = "CHANNEL_FOLLOW_REQUEST";
public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL";
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
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 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
*/
@NonNull
public static android.app.Notification make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account, boolean isOnlyOneInGroup) {
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
String mastodonNotificationId = body.getId();
int accountId = (int) account.getId();
// 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();
}
}
// Notification group member
// =========================
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()));
if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
builder.setStyle(new NotificationCompat.BigTextStyle()
.bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler())));
}
//load the avatar synchronously
Bitmap accountAvatar;
try {
FutureTarget<Bitmap> target = Glide.with(context)
.asBitmap()
.load(body.getAccount().getAvatar())
.transform(new RoundedCorners(20))
.submit();
accountAvatar = target.get();
} catch (ExecutionException | InterruptedException e) {
Log.d(TAG, "error loading account avatar", e);
accountAvatar = BitmapFactory.decodeResource(context.getResources(), R.drawable.avatar_default);
}
builder.setLargeIcon(accountAvatar);
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
if (body.getType() == Notification.Type.MENTION) {
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply),
quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput)
.build();
builder.addAction(quickReplyAction);
PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut),
composeIntent)
.setShowsUserInterface(true)
.build();
builder.addAction(composeAction);
}
builder.setSubText(account.getFullName());
builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
builder.setOnlyAlertOnce(true);
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().name());
builder.addExtras(extras);
// Only alert for the first notification of a batch to avoid multiple alerts at once
if(!isOnlyOneInGroup) {
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
}
return builder.build();
}
/**
* 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(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account) {
// Map from the channel ID to a list of notifications in that channel. Those are the
// notifications that will be summarised.
Map<String, List<StatusBarNotification>> channelGroups = new HashMap<>();
int accountId = (int) account.getId();
// Initialise the map with all channel IDs.
for (Notification.Type ty : Notification.Type.getEntries()) {
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
}
// 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;
String channelId = sn.getNotification().getGroup();
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
if (summaryTag.equals(sn.getTag())) continue;
// 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 typeName = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE, Notification.Type.UNKNOWN.name());
Notification.Type notificationType = Notification.Type.valueOf(typeName);
Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType);
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 newAndroidNotification(Context context, Notification body, AccountEntity account) {
Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType());
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
eventStackBuilder.addParentStack(MainActivity.class);
eventStackBuilder.addNextIntent(eventResultIntent);
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
pendingIntentFlags(false));
String channelId = getChannelId(account, body);
assert channelId != null;
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(eventResultPendingIntent)
.setColor(context.getColor(R.color.notification_color))
.setGroup(channelId)
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
setSoundVibrationLight(account, builder);
return builder;
}
private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
mentionedUsernames.add(mention.getUsername());
}
mentionedUsernames.removeAll(Collections.singleton(account.getUsername()));
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(REPLY_ACTION)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
.putExtra(KEY_SERVER_NOTIFICATION_ID, body.getId())
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
.putExtra(KEY_VISIBILITY, replyVisibility)
.putExtra(KEY_SPOILER, contentWarning)
.putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0]));
return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId,
replyIntent,
pendingIntentFlags(true));
}
private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = parseAsMastodonHtml(status.getContent()).toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> mentionedUsernames = new LinkedHashSet<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
String mentionedUsername = mention.getUsername();
if (!mentionedUsername.equals(account.getUsername())) {
mentionedUsernames.add(mention.getUsername());
}
}
ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions();
composeOptions.setInReplyToId(inReplyToId);
composeOptions.setReplyVisibility(replyVisibility);
composeOptions.setContentWarning(contentWarning);
composeOptions.setReplyingStatusAuthor(citedLocalAuthor);
composeOptions.setReplyingStatusContent(citedText);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(actionableStatus.getLanguage());
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId());
// make sure a new instance of MainActivity is started and old ones get destroyed
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
return PendingIntent.getActivity(context.getApplicationContext(),
notificationId,
composeIntent,
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) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
String[] channelIds = new String[]{
CHANNEL_MENTION + account.getIdentifier(),
CHANNEL_FOLLOW + account.getIdentifier(),
CHANNEL_FOLLOW_REQUEST + account.getIdentifier(),
CHANNEL_BOOST + account.getIdentifier(),
CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(),
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
CHANNEL_SIGN_UP + account.getIdentifier(),
CHANNEL_UPDATES + account.getIdentifier(),
CHANNEL_REPORT + account.getIdentifier(),
};
int[] channelNames = {
R.string.notification_mention_name,
R.string.notification_follow_name,
R.string.notification_follow_request_name,
R.string.notification_boost_name,
R.string.notification_favourite_name,
R.string.notification_poll_name,
R.string.notification_subscription_name,
R.string.notification_sign_up_name,
R.string.notification_update_name,
R.string.notification_report_name,
};
int[] channelDescriptions = {
R.string.notification_mention_descriptions,
R.string.notification_follow_description,
R.string.notification_follow_request_description,
R.string.notification_boost_description,
R.string.notification_favourite_description,
R.string.notification_poll_description,
R.string.notification_subscription_description,
R.string.notification_sign_up_description,
R.string.notification_update_description,
R.string.notification_report_description,
};
List<NotificationChannel> channels = new ArrayList<>(6);
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
notificationManager.createNotificationChannelGroup(channelGroup);
for (int i = 0; i < channelIds.length; i++) {
String id = channelIds[i];
String name = context.getString(channelNames[i]);
String description = context.getString(channelDescriptions[i]);
int importance = NotificationManager.IMPORTANCE_DEFAULT;
NotificationChannel channel = new NotificationChannel(id, name, importance);
channel.setDescription(description);
channel.enableLights(true);
channel.setLightColor(0xFF2B90D9);
channel.enableVibration(true);
channel.setShowBadge(true);
channel.setGroup(account.getIdentifier());
channels.add(channel);
}
notificationManager.createNotificationChannels(channels);
}
}
public static void deleteNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
}
}
public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// on Android >= O, notifications are enabled, if at least one channel is enabled
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager.areNotificationsEnabled()) {
for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
Log.d(TAG, "NotificationsEnabled");
return true;
}
}
}
Log.d(TAG, "NotificationsDisabled");
return false;
} else {
// on Android < O, notifications are enabled, if at least one account has notification enabled
return accountManager.areNotificationsEnabled();
}
}
public static void enablePullNotifications(@NonNull Context context) {
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,
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS
)
.addTag(NOTIFICATION_PULL_TAG)
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setInitialDelay(5, TimeUnit.MINUTES)
.build();
workManager.enqueue(workRequest);
Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval");
}
public static void disablePullNotifications(@NonNull Context context) {
WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
Log.d(TAG, "disabled notification checks");
}
public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) {
int accountId = (int) account.getId();
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(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) {
return filterNotification(notificationManager, account, notification.getType());
}
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
String channelId = getChannelId(account, type);
if(channelId == null) {
// unknown notificationtype
return false;
}
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
}
switch (type) {
case MENTION:
return account.getNotificationsMentioned();
case STATUS:
return account.getNotificationsSubscriptions();
case FOLLOW:
return account.getNotificationsFollowed();
case FOLLOW_REQUEST:
return account.getNotificationsFollowRequested();
case REBLOG:
return account.getNotificationsReblogged();
case FAVOURITE:
return account.getNotificationsFavorited();
case POLL:
return account.getNotificationsPolls();
case SIGN_UP:
return account.getNotificationsSignUps();
case UPDATE:
return account.getNotificationsUpdates();
case REPORT:
return account.getNotificationsReports();
default:
return false;
}
}
@Nullable
private static String getChannelId(AccountEntity account, Notification notification) {
return getChannelId(account, notification.getType());
}
@Nullable
private static String getChannelId(AccountEntity account, Notification.Type type) {
switch (type) {
case MENTION:
return CHANNEL_MENTION + account.getIdentifier();
case STATUS:
return CHANNEL_SUBSCRIPTIONS + account.getIdentifier();
case FOLLOW:
return CHANNEL_FOLLOW + account.getIdentifier();
case FOLLOW_REQUEST:
return CHANNEL_FOLLOW_REQUEST + account.getIdentifier();
case REBLOG:
return CHANNEL_BOOST + account.getIdentifier();
case FAVOURITE:
return CHANNEL_FAVOURITE + account.getIdentifier();
case POLL:
return CHANNEL_POLL + account.getIdentifier();
case SIGN_UP:
return CHANNEL_SIGN_UP + account.getIdentifier();
case UPDATE:
return CHANNEL_UPDATES + account.getIdentifier();
case REPORT:
return CHANNEL_REPORT + account.getIdentifier();
default:
return null;
}
}
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
}
if (account.getNotificationSound()) {
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
}
if (account.getNotificationVibration()) {
builder.setVibrate(new long[]{500, 500});
}
if (account.getNotificationLight()) {
builder.setLights(0xFF2B90D9, 300, 1000);
}
}
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, 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(notifications.get(length - 1)),
wrapItemAt(notifications.get(length - 2)),
wrapItemAt(notifications.get(length - 3)),
length - 3);
} else if (notifications.size() == 3) {
return String.format(context.getString(R.string.notification_summary_medium),
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(notifications.get(1)),
wrapItemAt(notifications.get(0)));
}
return null;
}
@Nullable
private static String titleForType(Context context, Notification notification, AccountEntity account) {
String accountName = StringUtils.unicodeWrap(notification.getAccount().getName());
switch (notification.getType()) {
case MENTION:
return String.format(context.getString(R.string.notification_mention_format),
accountName);
case STATUS:
return String.format(context.getString(R.string.notification_subscription_format),
accountName);
case FOLLOW:
return String.format(context.getString(R.string.notification_follow_format),
accountName);
case FOLLOW_REQUEST:
return String.format(context.getString(R.string.notification_follow_request_format),
accountName);
case FAVOURITE:
return String.format(context.getString(R.string.notification_favourite_format),
accountName);
case REBLOG:
return String.format(context.getString(R.string.notification_reblog_format),
accountName);
case POLL:
if(notification.getStatus().getAccount().getId().equals(account.getAccountId())) {
return context.getString(R.string.poll_ended_created);
} else {
return context.getString(R.string.poll_ended_voted);
}
case SIGN_UP:
return String.format(context.getString(R.string.notification_sign_up_format), accountName);
case UPDATE:
return String.format(context.getString(R.string.notification_update_format), accountName);
case REPORT:
return context.getString(R.string.notification_report_format, account.getDomain());
}
return null;
}
private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) {
switch (notification.getType()) {
case FOLLOW:
case FOLLOW_REQUEST:
case SIGN_UP:
return "@" + notification.getAccount().getUsername();
case MENTION:
case FAVOURITE:
case REBLOG:
case STATUS:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
return notification.getStatus().getSpoilerText();
} else {
return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
}
case POLL:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
return notification.getStatus().getSpoilerText();
} else {
StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
builder.append('\n');
Poll poll = notification.getStatus().getPoll();
List<PollOption> options = poll.getOptions();
for(int i = 0; i < options.size(); ++i) {
PollOption option = options.get(i);
builder.append(buildDescription(option.getTitle(),
PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()),
poll.getOwnVotes().contains(i),
context));
builder.append('\n');
}
return builder.toString();
}
case REPORT:
return context.getString(
R.string.notification_header_report_format,
StringUtils.unicodeWrap(notification.getAccount().getName()),
StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName())
);
}
return null;
}
public static int pendingIntentFlags(boolean mutable) {
if (mutable) {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
} else {
return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
}
}
}

View file

@ -0,0 +1,72 @@
/* Copyright 2024 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.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFilteredNotificationsInfoBinding
import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity
import com.keylesspalace.tusky.util.BindingHolder
import java.text.NumberFormat
class NotificationPolicySummaryAdapter(
private val onOpenDetails: () -> Unit
) : RecyclerView.Adapter<BindingHolder<ItemFilteredNotificationsInfoBinding>>() {
private var state: NotificationPolicyEntity? = null
fun updateState(newState: NotificationPolicyEntity?) {
val oldShowInfo = state.shouldShowInfo()
val newShowInfo = newState.shouldShowInfo()
state = newState
if (oldShowInfo && !newShowInfo) {
notifyItemRemoved(0)
} else if (!oldShowInfo && newShowInfo) {
notifyItemInserted(0)
} else if (oldShowInfo && newShowInfo) {
notifyItemChanged(0)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFilteredNotificationsInfoBinding> {
val binding = ItemFilteredNotificationsInfoBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
binding.root.setOnClickListener {
onOpenDetails()
}
return BindingHolder(binding)
}
override fun getItemCount() = if (state.shouldShowInfo()) 1 else 0
override fun onBindViewHolder(holder: BindingHolder<ItemFilteredNotificationsInfoBinding>, position: Int) {
state?.let { policyState ->
val binding = holder.binding
val context = holder.binding.root.context
binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policyState.pendingRequestsCount)
binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policyState.pendingNotificationsCount)
}
}
private fun NotificationPolicyEntity?.shouldShowInfo(): Boolean {
return this != null && this.pendingNotificationsCount > 0
}
}

View file

@ -0,0 +1,129 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toAccount
import com.keylesspalace.tusky.components.timeline.toStatus
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
import com.keylesspalace.tusky.db.entity.NotificationEntity
import com.keylesspalace.tusky.db.entity.NotificationReportEntity
import com.keylesspalace.tusky.db.entity.TimelineAccountEntity
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
fun Placeholder.toNotificationEntity(
tuskyAccountId: Long
) = NotificationEntity(
id = this.id,
tuskyAccountId = tuskyAccountId,
type = null,
accountId = null,
statusId = null,
reportId = null,
event = null,
moderationWarning = null,
loading = loading
)
fun Notification.toEntity(
tuskyAccountId: Long
) = NotificationEntity(
tuskyAccountId = tuskyAccountId,
type = type,
id = id,
accountId = account.id,
statusId = status?.reblog?.id ?: status?.id,
reportId = report?.id,
event = event,
moderationWarning = moderationWarning,
loading = false
)
fun Notification.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean,
): NotificationViewData.Concrete = NotificationViewData.Concrete(
id = id,
type = type,
account = account,
statusViewData = status?.toViewData(
isShowingContent = isShowingContent,
isExpanded = isExpanded,
isCollapsed = isCollapsed
),
report = report,
moderationWarning = moderationWarning,
event = event
)
fun Report.toEntity(
tuskyAccountId: Long
) = NotificationReportEntity(
tuskyAccountId = tuskyAccountId,
serverId = id,
category = category,
statusIds = statusIds,
createdAt = createdAt,
targetAccountId = targetAccount.id
)
fun NotificationDataEntity.toViewData(
translation: TranslationViewData? = null
): NotificationViewData {
if (type == null || account == null) {
return NotificationViewData.Placeholder(id = id, isLoading = loading)
}
return NotificationViewData.Concrete(
id = id,
type = type,
account = account.toAccount(),
statusViewData = if (status != null && statusAccount != null) {
StatusViewData.Concrete(
status = status.toStatus(statusAccount),
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
isCollapsed = this.status.contentCollapsed,
translation = translation
)
} else {
null
},
report = if (report != null && reportTargetAccount != null) {
report.toReport(reportTargetAccount)
} else {
null
},
event = event,
moderationWarning = moderationWarning
)
}
fun NotificationReportEntity.toReport(
account: TimelineAccountEntity
) = Report(
id = serverId,
category = category,
statusIds = statusIds,
createdAt = createdAt,
targetAccount = account.toAccount()
)

View file

@ -0,0 +1,572 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import android.content.DialogInterface
import android.content.SharedPreferences
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 android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.PopupWindow
import androidx.coordinatorlayout.widget.CoordinatorLayout
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.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding
import com.keylesspalace.tusky.databinding.NotificationsFilterBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.StatusProvider
import com.keylesspalace.tusky.util.ensureBottomPadding
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.updateRelativeTimePeriodically
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class NotificationsFragment :
SFragment(R.layout.fragment_timeline_notifications),
SwipeRefreshLayout.OnRefreshListener,
StatusActionListener,
NotificationActionListener,
AccountActionListener,
MenuProvider,
ReselectableFragment {
@Inject
lateinit var preferences: SharedPreferences
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var notificationService: NotificationService
private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind)
private val viewModel: NotificationsViewModel by viewModels()
private var notificationsAdapter: NotificationsPagingAdapter? = null
private var notificationsPolicyAdapter: NotificationPolicySummaryAdapter? = null
private var showNotificationsFilterBar: Boolean = true
private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST
/** see [com.keylesspalace.tusky.components.timeline.TimelineFragment] for explanation of the load more mechanism */
private var loadMorePosition: Int? = null
private var statusIdBelowLoadMore: String? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val activeAccount = accountManager.activeAccount ?: return
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = activeAccount.mediaPreviewEnabled,
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) {
CardViewMode.INDENTED
} else {
CardViewMode.NONE
},
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia,
openSpoiler = activeAccount.alwaysOpenSpoiler
)
binding.recyclerView.ensureBottomPadding(fab = true)
// setup the notifications filter bar
showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
updateFilterBarVisibility()
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
binding.buttonFilter.setOnClickListener { showFilterMenu() }
// Setup the SwipeRefreshLayout.
binding.swipeRefreshLayout.setOnRefreshListener(this)
// Setup the RecyclerView.
binding.recyclerView.setHasFixedSize(true)
val adapter = NotificationsPagingAdapter(
accountId = activeAccount.accountId,
statusListener = this,
notificationActionListener = this,
accountActionListener = this,
statusDisplayOptions = statusDisplayOptions,
instanceName = activeAccount.domain
)
this.notificationsAdapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(
binding.recyclerView,
this,
StatusProvider { pos: Int ->
if (pos in 0 until adapter.itemCount) {
val notification = adapter.peek(pos)
// We support replies only for now
if (notification is NotificationViewData.Concrete) {
return@StatusProvider notification.statusViewData
} else {
return@StatusProvider null
}
} else {
null
}
}
)
)
val notificationsPolicyAdapter = NotificationPolicySummaryAdapter {
(activity as BaseActivity).startActivityWithSlideInAnimation(NotificationRequestsActivity.newIntent(requireContext()))
}
this.notificationsPolicyAdapter = notificationsPolicyAdapter
binding.recyclerView.adapter = ConcatAdapter(notificationsPolicyAdapter, notificationsAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.addItemDecoration(
DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
)
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
notificationsPolicyAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
binding.recyclerView.scrollToPosition(0)
}
})
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
binding.statusView.hide()
binding.progressBar.hide()
if (adapter.itemCount == 0) {
when (loadState.refresh) {
is LoadState.NotLoading -> {
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
}
}
is LoadState.Error -> {
binding.statusView.show()
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() }
}
is LoadState.Loading -> {
binding.progressBar.show()
}
}
}
}
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition()
if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (getView() != null) {
binding.recyclerView.scrollBy(
0,
Utils.dpToPx(binding.recyclerView.context, -30)
)
}
}
loadMorePosition = null
}
if (readingOrder == ReadingOrder.OLDEST_FIRST) {
updateReadingPositionForOldestFirst(adapter)
}
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewModel.notifications.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
viewLifecycleOwner.lifecycleScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(adapter, event.preferenceKey)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
accountManager.activeAccount?.let { account ->
notificationService.clearNotificationsForAccount(account)
}
}
}
updateRelativeTimePeriodically(preferences, adapter)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.notificationPolicy.collect {
notificationsPolicyAdapter.updateState(it)
}
}
}
override fun onDestroyView() {
// Clear the adapters to prevent leaking the View
notificationsAdapter = null
notificationsPolicyAdapter = null
super.onDestroyView()
}
override fun onReselect() {
if (view != null) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
}
override fun onRefresh() {
notificationsAdapter?.refresh()
viewModel.loadNotificationPolicy()
}
override fun onViewAccount(id: String) {
super.viewAccount(id)
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
// not needed, muting via the more menu on statuses is handled in SFragment
}
override fun onBlock(block: Boolean, id: String, position: Int) {
// not needed, blocking via the more menu on statuses is handled in SFragment
}
override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) {
val notification = notificationsAdapter?.peek(position) ?: return
viewModel.respondToFollowRequest(accept, accountIdRequestingFollow = accountIdRequestingFollow, notificationId = notification.id)
}
override fun onViewReport(reportId: String) {
requireContext().openLink(
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
)
}
override fun onViewTag(tag: String) {
super.viewTag(tag)
}
override fun onReply(position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.reply(status.status)
}
override fun removeItem(position: Int) {
val notification = notificationsAdapter?.peek(position) ?: return
viewModel.remove(notification.id)
}
override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.reblog(reblog, status, visibility)
}
override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit
get() = { translate: Boolean, position: Int ->
if (translate) {
onTranslate(position)
} else {
onUntranslate(position)
}
}
private fun onTranslate(position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewLifecycleOwner.lifecycleScope.launch {
viewModel.translate(status)
.onFailure {
Snackbar.make(
requireView(),
getString(R.string.ui_error_translate, it.message),
Snackbar.LENGTH_LONG
).show()
}
}
}
override fun onUntranslate(position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.untranslate(status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.favorite(favourite, status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.bookmark(bookmark, status)
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.voteInPoll(choices, status)
}
override fun clearWarningAction(position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.clearWarning(status)
}
override fun onMore(view: View, position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.more(
status.status,
view,
position,
(status.translation as? TranslationViewData.Loaded)?.data
)
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view)
}
override fun onViewThread(position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull()?.status ?: return
super.viewThread(status.id, status.url)
}
override fun onOpenReblog(position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
super.openReblog(status.status)
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeExpanded(expanded, status)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentShowing(isShowing, status)
}
override fun onLoadMore(position: Int) {
val adapter = this.notificationsAdapter
val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return
loadMorePosition = position
statusIdBelowLoadMore =
if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null
viewModel.loadMore(placeholder.id)
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return
viewModel.changeContentCollapsed(isCollapsed, status)
}
private fun confirmClearNotifications() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.notification_clear_text)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun clearNotifications() {
viewModel.clearNotifications()
}
private fun showFilterMenu() {
val notificationTypeList = NotificationChannelData.entries.map { type ->
getString(type.title)
}
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList)
val window = PopupWindow(requireContext(), null, com.google.android.material.R.attr.listPopupWindowStyle)
val menuBinding = NotificationsFilterBinding.inflate(LayoutInflater.from(requireContext()), binding.root as ViewGroup, false)
menuBinding.buttonApply.setOnClickListener {
val checkedItems = menuBinding.listView.getCheckedItemPositions()
val excludes = NotificationChannelData.entries.filterIndexed { index, _ ->
!checkedItems[index, false]
}
window.dismiss()
viewModel.updateNotificationFilters(excludes.toSet())
}
menuBinding.listView.setAdapter(adapter)
menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE)
NotificationChannelData.entries.forEachIndexed { index, type ->
menuBinding.listView.setItemChecked(index, !viewModel.excludes.value.contains(type))
}
window.setContentView(menuBinding.root)
window.isFocusable = true
window.width = ViewGroup.LayoutParams.WRAP_CONTENT
window.height = ViewGroup.LayoutParams.WRAP_CONTENT
window.showAsDropDown(binding.buttonFilter)
}
private fun onPreferenceChanged(adapter: NotificationsPagingAdapter, key: String) {
when (key) {
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
if (enabled != oldMediaPreviewEnabled) {
adapter.mediaPreviewEnabled = enabled
}
}
PrefKeys.SHOW_NOTIFICATIONS_FILTER -> {
if (view != null) {
showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
updateFilterBarVisibility()
}
}
PrefKeys.READING_ORDER -> {
readingOrder = ReadingOrder.from(
preferences.getString(PrefKeys.READING_ORDER, null)
)
}
}
}
private fun updateFilterBarVisibility() {
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
if (showNotificationsFilterBar) {
binding.appBarOptions.setExpanded(true, false)
binding.appBarOptions.show()
// Set content behaviour to hide filter on scroll
params.behavior = AppBarLayout.ScrollingViewBehavior()
} else {
binding.appBarOptions.setExpanded(false, false)
binding.appBarOptions.hide()
// Clear behaviour to hide app bar
params.behavior = null
}
}
private fun updateReadingPositionForOldestFirst(adapter: NotificationsPagingAdapter) {
var position = loadMorePosition ?: return
val notificationIdBelowLoadMore = statusIdBelowLoadMore ?: return
var notification: NotificationViewData?
while (adapter.peek(position).let {
notification = it
it != null
}
) {
if (notification?.id == notificationIdBelowLoadMore) {
val lastVisiblePosition =
(binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
if (position > lastVisiblePosition) {
binding.recyclerView.scrollToPosition(position)
}
break
}
position++
}
loadMorePosition = null
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_notifications, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) {
R.id.action_refresh -> {
binding.swipeRefreshLayout.isRefreshing = true
onRefresh()
true
}
R.id.action_edit_notification_filter -> {
showFilterMenu()
true
}
R.id.action_clear_notifications -> {
confirmClearNotifications()
true
}
else -> false
}
companion object {
fun newInstance() = NotificationsFragment()
}
}

View file

@ -0,0 +1,220 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.ItemFollowBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding
import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding
import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
interface NotificationActionListener {
fun onViewReport(reportId: String)
}
interface NotificationsViewHolder {
fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
)
}
class NotificationsPagingAdapter(
private val accountId: String,
private var statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener,
private val notificationActionListener: NotificationActionListener,
private val accountActionListener: AccountActionListener,
private val instanceName: String
) : PagingDataAdapter<NotificationViewData, RecyclerView.ViewHolder>(NotificationsDifferCallback) {
var mediaPreviewEnabled: Boolean
get() = statusDisplayOptions.mediaPreviewEnabled
set(mediaPreviewEnabled) {
statusDisplayOptions = statusDisplayOptions.copy(
mediaPreviewEnabled = mediaPreviewEnabled
)
notifyItemRangeChanged(0, itemCount)
}
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun getItemViewType(position: Int): Int {
return when (val notification = getItem(position)) {
is NotificationViewData.Concrete -> {
when (notification.type) {
Notification.Type.Mention,
Notification.Type.Poll -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS
}
Notification.Type.Status,
Notification.Type.Update -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else {
VIEW_TYPE_STATUS_NOTIFICATION
}
Notification.Type.Favourite,
Notification.Type.Reblog -> VIEW_TYPE_STATUS_NOTIFICATION
Notification.Type.Follow,
Notification.Type.SignUp -> VIEW_TYPE_FOLLOW
Notification.Type.FollowRequest -> VIEW_TYPE_FOLLOW_REQUEST
Notification.Type.Report -> VIEW_TYPE_REPORT
Notification.Type.SeveredRelationship -> VIEW_TYPE_SEVERED_RELATIONSHIP
Notification.Type.ModerationWarning -> VIEW_TYPE_MODERATION_WARNING
else -> VIEW_TYPE_UNKNOWN
}
}
else -> VIEW_TYPE_PLACEHOLDER
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_STATUS -> StatusViewHolder(
inflater.inflate(R.layout.item_status, parent, false),
statusListener,
accountId
)
VIEW_TYPE_STATUS_FILTERED -> FilteredStatusViewHolder(
ItemStatusFilteredBinding.inflate(inflater, parent, false),
statusListener
)
VIEW_TYPE_STATUS_NOTIFICATION -> StatusNotificationViewHolder(
ItemStatusNotificationBinding.inflate(inflater, parent, false),
statusListener,
absoluteTimeFormatter
)
VIEW_TYPE_FOLLOW -> FollowViewHolder(
ItemFollowBinding.inflate(inflater, parent, false),
accountActionListener,
statusListener
)
VIEW_TYPE_FOLLOW_REQUEST -> FollowRequestViewHolder(
ItemFollowRequestBinding.inflate(inflater, parent, false),
accountActionListener,
statusListener,
true
)
VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder(
ItemStatusPlaceholderBinding.inflate(inflater, parent, false),
statusListener
)
VIEW_TYPE_REPORT -> ReportNotificationViewHolder(
ItemReportNotificationBinding.inflate(inflater, parent, false),
notificationActionListener,
accountActionListener
)
VIEW_TYPE_SEVERED_RELATIONSHIP -> SeveredRelationshipNotificationViewHolder(
ItemSeveredRelationshipNotificationBinding.inflate(inflater, parent, false),
instanceName
)
VIEW_TYPE_MODERATION_WARNING -> ModerationWarningViewHolder(
ItemModerationWarningNotificationBinding.inflate(inflater, parent, false),
instanceName
)
else -> UnknownNotificationViewHolder(
ItemUnknownNotificationBinding.inflate(inflater, parent, false)
)
}
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
onBindViewHolder(viewHolder, position, emptyList())
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List<Any>) {
getItem(position)?.let { notification ->
when (notification) {
is NotificationViewData.Concrete ->
(viewHolder as NotificationsViewHolder).bind(notification, payloads, statusDisplayOptions)
is NotificationViewData.Placeholder -> {
(viewHolder as PlaceholderViewHolder).setup(notification.isLoading)
}
}
}
}
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_FILTERED = 1
private const val VIEW_TYPE_STATUS_NOTIFICATION = 2
private const val VIEW_TYPE_FOLLOW = 3
private const val VIEW_TYPE_FOLLOW_REQUEST = 4
private const val VIEW_TYPE_PLACEHOLDER = 5
private const val VIEW_TYPE_REPORT = 6
private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 7
private const val VIEW_TYPE_MODERATION_WARNING = 8
private const val VIEW_TYPE_UNKNOWN = 9
val NotificationsDifferCallback = 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 // Items are different always. It allows to refresh timestamp on every view holder update
}
override fun getChangePayload(
oldItem: NotificationViewData,
newItem: NotificationViewData
): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
StatusBaseViewHolder.Key.KEY_CREATED
} else {
// If items are different - update the whole view holder
null
}
}
}
}
}

View file

@ -0,0 +1,213 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.components.systemnotifications.toTypes
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isLessThan
import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class NotificationsRemoteMediator(
private val viewModel: NotificationsViewModel,
private val accountManager: AccountManager,
private val api: MastodonApi,
private val db: AppDatabase
) : RemoteMediator<Int, NotificationDataEntity>() {
private var initialRefresh = false
private val notificationsDao = db.notificationsDao()
private val accountDao = db.timelineAccountDao()
private val statusDao = db.timelineStatusDao()
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, NotificationDataEntity>
): MediatorResult {
val activeAccount = viewModel.activeAccountFlow.value
if (activeAccount == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
val excludes = viewModel.excludes.value.toTypes()
try {
var dbEmpty = false
val topPlaceholderId = if (loadType == LoadType.REFRESH) {
notificationsDao.getTopPlaceholderId(activeAccount.id)
} else {
null // don't execute the query if it is not needed
}
if (!initialRefresh && loadType == LoadType.REFRESH) {
val topId = notificationsDao.getTopId(activeAccount.id)
topId?.let { cachedTopId ->
val notificationResponse = api.notifications(
maxId = cachedTopId,
// so already existing placeholders don't get accidentally overwritten
sinceId = topPlaceholderId,
limit = state.config.pageSize,
excludes = excludes
)
val notifications = notificationResponse.body()
if (notificationResponse.isSuccessful && notifications != null) {
db.withTransaction {
replaceNotificationRange(notifications, state, activeAccount)
}
}
}
initialRefresh = true
dbEmpty = topId == null
}
val notificationResponse = when (loadType) {
LoadType.REFRESH -> {
api.notifications(sinceId = topPlaceholderId, limit = state.config.pageSize, excludes = excludes)
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.id
api.notifications(maxId = maxId, limit = state.config.pageSize, excludes = excludes)
}
}
val notifications = notificationResponse.body()
if (!notificationResponse.isSuccessful || notifications == null) {
return MediatorResult.Error(HttpException(notificationResponse))
}
db.withTransaction {
val overlappedNotifications = replaceNotificationRange(notifications, state, activeAccount)
/* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
if (loadType == LoadType.REFRESH && overlappedNotifications == 0 && notifications.size == state.config.pageSize && !dbEmpty) {
/* This overrides the last of the newly loaded statuses with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
notificationsDao.insertNotification(
Placeholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id)
)
}
}
return MediatorResult.Success(endOfPaginationReached = notifications.isEmpty())
} catch (e: Exception) {
return ifExpected(e) {
Log.w(TAG, "Failed to load notifications", e)
MediatorResult.Error(e)
}
}
}
/**
* Deletes all notifications in a given range and inserts new notifications.
* This is necessary so notifications that have been deleted on the server are cleaned up.
* Should be run in a transaction as it executes multiple db updates
* @param notifications the new notifications
* @return the number of old notifications that have been cleared from the database
*/
private suspend fun replaceNotificationRange(
notifications: List<Notification>,
state: PagingState<Int, NotificationDataEntity>,
activeAccount: AccountEntity
): Int {
val overlappedNotifications = if (notifications.isNotEmpty()) {
notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id)
} else {
0
}
for (notification in notifications) {
accountDao.insert(notification.account.toEntity(activeAccount.id))
notification.report?.let { report ->
accountDao.insert(report.targetAccount.toEntity(activeAccount.id))
notificationsDao.insertReport(report.toEntity(activeAccount.id))
}
// check if we already have one of the newly loaded statuses cached locally
// in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost
var oldStatus: TimelineStatusEntity? = null
for (page in state.pages) {
oldStatus = page.data.find { s ->
s.id == notification.id
}?.status
if (oldStatus != null) break
}
notification.status?.let { status ->
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.sensitive)
val contentCollapsed = oldStatus?.contentCollapsed ?: true
val statusToInsert = status.reblog ?: status
accountDao.insert(statusToInsert.account.toEntity(activeAccount.id))
statusDao.insert(
statusToInsert.toEntity(
tuskyAccountId = activeAccount.id,
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed
)
)
}
notificationsDao.insertNotification(
notification.toEntity(
activeAccount.id
)
)
}
notifications.firstOrNull()?.let { notification ->
saveNewestNotificationId(notification)
}
return overlappedNotifications
}
private suspend fun saveNewestNotificationId(notification: Notification) {
viewModel.activeAccountFlow.value?.let { activeAccount ->
val lastNotificationId: String = activeAccount.lastNotificationId
val newestNotificationId = notification.id
if (lastNotificationId.isLessThan(newestNotificationId)) {
Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${activeAccount.id}")
accountManager.updateAccount(activeAccount) { copy(lastNotificationId = newestNotificationId) }
}
}
}
companion object {
private const val TAG = "NotificationsRM"
}
}

View file

@ -0,0 +1,441 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.notifications
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.filter
import androidx.paging.map
import androidx.room.withTransaction
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.map
import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData
import com.keylesspalace.tusky.components.systemnotifications.toTypes
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import retrofit2.HttpException
@HiltViewModel
class NotificationsViewModel @Inject constructor(
private val timelineCases: TimelineCases,
private val api: MastodonApi,
eventHub: EventHub,
private val accountManager: AccountManager,
private val preferences: SharedPreferences,
private val filterModel: FilterModel,
private val db: AppDatabase,
private val notificationPolicyUsecase: NotificationPolicyUsecase
) : ViewModel() {
val activeAccountFlow = accountManager.activeAccount(viewModelScope)
private val accountId: Long = activeAccountFlow.value!!.id
private val refreshTrigger = MutableStateFlow(0L)
val excludes: StateFlow<Set<NotificationChannelData>> = activeAccountFlow
.map { account -> account?.notificationsFilter.orEmpty() }
.stateIn(viewModelScope, SharingStarted.Eagerly, activeAccountFlow.value?.notificationsFilter.orEmpty())
/** Map from notification id to translation. */
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
private var remoteMediator = NotificationsRemoteMediator(this, accountManager, api, db)
private var readingOrder: ReadingOrder =
ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
@OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class)
val notifications = refreshTrigger.flatMapLatest {
Pager(
config = PagingConfig(
pageSize = LOAD_AT_ONCE
),
remoteMediator = remoteMediator,
pagingSourceFactory = {
db.notificationsDao().getNotifications(accountId)
}
).flow
.cachedIn(viewModelScope)
.combine(translations) { pagingData, translations ->
pagingData.map { notification ->
val translation = translations[notification.status?.serverId]
notification.toViewData(translation = translation)
}.filter { notificationViewData ->
shouldFilterStatus(notificationViewData) != Filter.Action.HIDE
}
}
}
.flowOn(Dispatchers.Default)
val notificationPolicy: Flow<NotificationPolicyEntity?> = notificationPolicyUsecase.info
init {
viewModelScope.launch {
eventHub.events.collect { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
}
if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS.kind)) {
filterModel.init(Filter.Kind.NOTIFICATIONS)
refreshTrigger.value += 1
}
}
}
viewModelScope.launch {
val needsRefresh = filterModel.init(Filter.Kind.NOTIFICATIONS)
if (needsRefresh) {
refreshTrigger.value++
}
}
loadNotificationPolicy()
}
fun loadNotificationPolicy() {
viewModelScope.launch {
notificationPolicyUsecase.getNotificationPolicy()
}
}
fun updateNotificationFilters(newFilters: Set<NotificationChannelData>) {
val account = activeAccountFlow.value
if (newFilters != excludes.value && account != null) {
viewModelScope.launch {
accountManager.updateAccount(account) {
copy(notificationsFilter = newFilters)
}
db.notificationsDao().cleanupNotifications(accountId, 0)
refreshTrigger.value++
}
}
}
private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action {
return when ((notificationViewData as? NotificationViewData.Concrete)?.type) {
Notification.Type.Mention, Notification.Type.Poll, Notification.Type.Status, Notification.Type.Update -> {
val account = activeAccountFlow.value
notificationViewData.statusViewData?.let { statusViewData ->
if (statusViewData.status.account.id == account?.accountId) {
return Filter.Action.NONE
}
statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable)
return statusViewData.filterAction
}
Filter.Action.NONE
}
else -> Filter.Action.NONE
}
}
fun respondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, notificationId: String) {
viewModelScope.launch {
if (accept) {
api.authorizeFollowRequest(accountIdRequestingFollow)
} else {
api.rejectFollowRequest(accountIdRequestingFollow)
}.fold(
onSuccess = {
// since the follow request has been responded, the notification can be deleted. The Ui will update automatically.
db.notificationsDao().delete(accountId, notificationId)
if (accept) {
// refresh the notifications so the new follow notification will be loaded
refreshTrigger.value++
}
},
onFailure = { t ->
Log.e(TAG, "Failed to to respond to follow request from account id $accountIdRequestingFollow.", t)
}
)
}
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch {
timelineCases.reblog(status.actionableId, reblog, visibility).onFailure { t ->
ifExpected(t) {
Log.w(TAG, "Failed to reblog status " + status.actionableId, t)
}
}
}
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
timelineCases.favourite(status.actionableId, favorite).onFailure { t ->
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
}
}
}
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
timelineCases.bookmark(status.actionableId, bookmark).onFailure { t ->
ifExpected(t) {
Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
}
}
}
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete) = viewModelScope.launch {
val poll = status.status.actionableStatus.poll ?: run {
Log.d(TAG, "No poll on status ${status.id}")
return@launch
}
timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t ->
ifExpected(t) {
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
}
}
}
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setExpanded(accountId, status.id, expanded)
}
}
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentShowing(accountId, status.id, isShowing)
}
}
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentCollapsed(accountId, status.id, isCollapsed)
}
}
fun remove(notificationId: String) {
viewModelScope.launch {
db.notificationsDao().delete(accountId, notificationId)
}
}
fun clearWarning(status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao().clearWarning(accountId, status.actionableId)
}
}
fun clearNotifications() {
viewModelScope.launch {
api.clearNotifications().fold(
{
db.notificationsDao().cleanupNotifications(accountId, 0)
},
{ t ->
Log.w(TAG, "failed to clear notifications", t)
}
)
}
}
suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> {
translations.value += (status.id to TranslationViewData.Loading)
return timelineCases.translate(status.actionableId)
.map { translation ->
translations.value += (status.id to TranslationViewData.Loaded(translation))
}
.onFailure {
translations.value -= status.id
}
}
fun untranslate(status: StatusViewData.Concrete) {
translations.value -= status.id
}
fun loadMore(placeholderId: String) {
viewModelScope.launch {
try {
val notificationsDao = db.notificationsDao()
notificationsDao.insertNotification(
Placeholder(placeholderId, loading = true).toNotificationEntity(
accountId
)
)
val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction {
notificationsDao.getIdAbove(accountId, placeholderId) to
notificationsDao.getIdBelow(accountId, placeholderId)
}
val response = when (readingOrder) {
// Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately
// after minId and no larger than maxId
ReadingOrder.OLDEST_FIRST -> api.notifications(
maxId = idAbovePlaceholder,
minId = idBelowPlaceholder,
limit = TimelineViewModel.LOAD_AT_ONCE,
excludes = excludes.value.toTypes()
)
// Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before
// maxId, and no smaller than minId.
ReadingOrder.NEWEST_FIRST -> api.notifications(
maxId = idAbovePlaceholder,
sinceId = idBelowPlaceholder,
limit = TimelineViewModel.LOAD_AT_ONCE,
excludes = excludes.value.toTypes()
)
}
val notifications = response.body()
if (!response.isSuccessful || notifications == null) {
loadMoreFailed(placeholderId, HttpException(response))
return@launch
}
val account = activeAccountFlow.value
if (account == null) {
return@launch
}
val statusDao = db.timelineStatusDao()
val accountDao = db.timelineAccountDao()
db.withTransaction {
notificationsDao.delete(accountId, placeholderId)
val overlappedNotifications = if (notifications.isNotEmpty()) {
notificationsDao.deleteRange(
accountId,
notifications.last().id,
notifications.first().id
)
} else {
0
}
for (notification in notifications) {
accountDao.insert(notification.account.toEntity(accountId))
notification.report?.let { report ->
accountDao.insert(report.targetAccount.toEntity(accountId))
notificationsDao.insertReport(report.toEntity(accountId))
}
notification.status?.let { status ->
val statusToInsert = status.reblog ?: status
accountDao.insert(statusToInsert.account.toEntity(accountId))
statusDao.insert(
statusToInsert.toEntity(
tuskyAccountId = accountId,
expanded = account.alwaysOpenSpoiler,
contentShowing = account.alwaysShowSensitiveMedia || !status.sensitive,
contentCollapsed = true
)
)
}
notificationsDao.insertNotification(
notification.toEntity(
accountId
)
)
}
/* In case we loaded a whole page and there was no overlap with existing notifications,
we insert a placeholder because there might be even more unknown notifications */
if (overlappedNotifications == 0 && notifications.size == TimelineViewModel.LOAD_AT_ONCE) {
/* This overrides the first/last of the newly loaded notifications with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
val idToConvert = when (readingOrder) {
ReadingOrder.OLDEST_FIRST -> notifications.first().id
ReadingOrder.NEWEST_FIRST -> notifications.last().id
}
notificationsDao.insertNotification(
Placeholder(
idToConvert,
loading = false
).toNotificationEntity(accountId)
)
}
}
} catch (e: Exception) {
ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
}
}
}
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
Log.w(TAG, "failed loading notifications", e)
val activeAccount = accountManager.activeAccount!!
db.notificationsDao()
.insertNotification(
Placeholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id)
)
}
private fun onPreferenceChanged(key: String) {
when (key) {
PrefKeys.READING_ORDER -> {
readingOrder = ReadingOrder.from(
preferences.getString(PrefKeys.READING_ORDER, null)
)
}
}
}
companion object {
private const val LOAD_AT_ONCE = 30
private const val TAG = "NotificationsViewModel"
}
}

View file

@ -1,262 +0,0 @@
/* Copyright 2022 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>. */
@file:JvmName("PushNotificationHelper")
package com.keylesspalace.tusky.components.notifications
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceManager
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.CryptoUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.unifiedpush.android.connector.UnifiedPush
private const val TAG = "PushNotificationHelper"
private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.accounts.any(::accountNeedsMigration)
private fun accountNeedsMigration(account: AccountEntity): Boolean =
!account.oauthScopes.contains("push")
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
fun showMigrationNoticeIfNecessary(
context: Context,
parent: View,
anchorView: View?,
accountManager: AccountManager
) {
// No point showing anything if we cannot enable it
if (!isUnifiedPushAvailable(context)) return
if (!anyAccountNeedsMigration(accountManager)) return
val pm = PreferenceManager.getDefaultSharedPreferences(context)
if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(anchorView)
.setAction(
R.string.action_details
) { showMigrationExplanationDialog(context, accountManager) }
.show()
}
private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
AlertDialog.Builder(context).apply {
if (currentAccountNeedsMigration(accountManager)) {
setMessage(R.string.dialog_push_notification_migration)
setPositiveButton(R.string.title_migration_relogin) { _, _ ->
context.startActivity(
LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
)
}
} else {
setMessage(R.string.dialog_push_notification_migration_other_accounts)
}
setNegativeButton(R.string.action_dismiss) { dialog, _ ->
val pm = PreferenceManager.getDefaultSharedPreferences(context)
pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply()
dialog.dismiss()
}
show()
}
}
private suspend fun enableUnifiedPushNotificationsForAccount(
context: Context,
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity
) {
if (isUnifiedPushNotificationEnabledForAccount(account)) {
// Already registered, update the subscription to match notification settings
updateUnifiedPushSubscription(context, api, accountManager, account)
} else {
UnifiedPush.registerAppWithDialog(
context,
account.id.toString(),
features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)
)
}
}
fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) {
if (!isUnifiedPushNotificationEnabledForAccount(account)) {
// Not registered
return
}
UnifiedPush.unregisterApp(context, account.id.toString())
}
fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
account.unifiedPushUrl.isNotEmpty()
private fun isUnifiedPushAvailable(context: Context): Boolean =
UnifiedPush.getDistributors(context).isNotEmpty()
fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean =
isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager)
suspend fun enablePushNotificationsWithFallback(
context: Context,
api: MastodonApi,
accountManager: AccountManager
) {
if (!canEnablePushNotifications(context, accountManager)) {
// No UP distributors
NotificationHelper.enablePullNotifications(context)
return
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
accountManager.accounts.forEach {
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
if (shouldEnable) {
enableUnifiedPushNotificationsForAccount(context, api, accountManager, it)
} else {
disableUnifiedPushNotificationsForAccount(context, it)
}
}
}
private fun disablePushNotifications(context: Context, accountManager: AccountManager) {
accountManager.accounts.forEach {
disableUnifiedPushNotificationsForAccount(context, it)
}
}
fun disableAllNotifications(context: Context, accountManager: AccountManager) {
disablePushNotifications(context, accountManager)
NotificationHelper.disablePullNotifications(context)
}
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
buildMap {
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
Notification.Type.visibleTypes.forEach {
put(
"data[alerts][${it.presentation}]",
NotificationHelper.filterNotification(notificationManager, account, it)
)
}
}
// Called by UnifiedPush callback
suspend fun registerUnifiedPushEndpoint(
context: Context,
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity,
endpoint: String
) = withContext(Dispatchers.IO) {
// Generate a prime256v1 key pair for WebPush
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
// standard which does not send needed information for decryption in the payload
// This makes it not directly compatible with UnifiedPush
// As of now, we use it purely as a way to trigger a pull
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
val auth = CryptoUtil.secureRandomBytesEncoded(16)
api.subscribePushNotifications(
"Bearer ${account.accessToken}",
account.domain,
endpoint,
keyPair.pubkey,
auth,
buildSubscriptionData(context, account)
).onFailure { throwable ->
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
disableUnifiedPushNotificationsForAccount(context, account)
}.onSuccess {
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
account.pushPubKey = keyPair.pubkey
account.pushPrivKey = keyPair.privKey
account.pushAuth = auth
account.pushServerKey = it.serverKey
account.unifiedPushUrl = endpoint
accountManager.saveAccount(account)
}
}
// Synchronize the enabled / disabled state of notifications with server-side subscription
suspend fun updateUnifiedPushSubscription(
context: Context,
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity
) {
withContext(Dispatchers.IO) {
api.updatePushNotificationSubscription(
"Bearer ${account.accessToken}",
account.domain,
buildSubscriptionData(context, account)
).onSuccess {
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
account.pushServerKey = it.serverKey
accountManager.saveAccount(account)
}
}
}
suspend fun unregisterUnifiedPushEndpoint(
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity
) {
withContext(Dispatchers.IO) {
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
.onFailure { throwable ->
Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
}
.onSuccess {
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
// Clear the URL in database
account.unifiedPushUrl = ""
account.pushServerKey = ""
account.pushAuth = ""
account.pushPrivKey = ""
account.pushPubKey = ""
accountManager.saveAccount(account)
}
}
}

View file

@ -13,86 +13,85 @@
* 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.notifications
import android.content.Context
import androidx.core.content.ContextCompat
import android.text.TextUtils
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.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.updateEmojiTargets
import com.keylesspalace.tusky.viewdata.NotificationViewData
class ReportNotificationViewHolder(
private val binding: ItemReportNotificationBinding
) : RecyclerView.ViewHolder(binding.root) {
private val binding: ItemReportNotificationBinding,
private val listener: NotificationActionListener,
private val accountActionListener: AccountActionListener
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
fun setupWithReport(
reporter: TimelineAccount,
report: Report,
animateAvatar: Boolean,
animateEmojis: Boolean
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, binding.notificationTopText, animateEmojis)
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, binding.notificationTopText, animateEmojis)
if (payloads.isNotEmpty()) {
return
}
val report = viewData.report!!
val reporter = viewData.account
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
binding.notificationTopText.updateEmojiTargets {
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, statusDisplayOptions.animateEmojis)
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, statusDisplayOptions.animateEmojis)
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
// Context.getString() returns a String and doesn't support Spannable.
// Convert the placeholders to the format used by TextUtils.expandTemplate which does.
val topText =
view.context.getString(R.string.notification_header_report_format, "^1", "^2")
view.text = TextUtils.expandTemplate(topText, reporterName, reporteeName)
}
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0)
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
// Fancy avatar inset
val padding = Utils.dpToPx(binding.notificationReporteeAvatar.context, 12)
binding.notificationReporteeAvatar.setPaddingRelative(0, 0, padding, padding)
loadAvatar(
report.targetAccount.avatar,
binding.notificationReporteeAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
animateAvatar
statusDisplayOptions.animateAvatars,
)
loadAvatar(
reporter.avatar,
binding.notificationReporterAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
animateAvatar
statusDisplayOptions.animateAvatars,
)
}
fun setupActionListener(
listener: NotificationActionListener,
reporteeId: String,
reporterId: String,
reportId: String
) {
binding.notificationReporteeAvatar.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onViewAccount(reporteeId)
accountActionListener.onViewAccount(report.targetAccount.id)
}
}
binding.notificationReporterAvatar.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onViewAccount(reporterId)
accountActionListener.onViewAccount(reporter.id)
}
}
itemView.setOnClickListener { listener.onViewReport(reportId) }
itemView.setOnClickListener { listener.onViewReport(report.id) }
}
private fun getTranslatedCategory(context: Context, rawCategory: String): String {
return when (rawCategory) {
"violation" -> context.getString(R.string.report_category_violation)
"spam" -> context.getString(R.string.report_category_spam)
"legal" -> context.getString(R.string.report_category_legal)
"other" -> context.getString(R.string.report_category_other)
else -> rawCategory
}

View file

@ -0,0 +1,46 @@
/* Copyright 2025 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.components.systemnotifications.NotificationService
import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
class SeveredRelationshipNotificationViewHolder(
private val binding: ItemSeveredRelationshipNotificationBinding,
private val instanceName: String
) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
if (payloads.isNotEmpty()) {
return
}
val event = viewData.event!!
val context = binding.root.context
binding.severedRelationshipText.text = NotificationService.severedRelationShipText(
context,
event,
instanceName
)
}
}

View file

@ -0,0 +1,390 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.InputFilter
import android.text.Spanned
import android.text.format.DateUtils
import android.text.style.StyleSpan
import android.view.View
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.text.toSpannable
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.SmartLengthInputFilter
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
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
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
internal class StatusNotificationViewHolder(
private val binding: ItemStatusNotificationBinding,
private val statusActionListener: StatusActionListener,
private val absoluteTimeFormatter: AbsoluteTimeFormatter
) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) {
private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_48dp
)
private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_36dp
)
private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
R.dimen.avatar_radius_24dp
)
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
val statusViewData = viewData.statusViewData
if (payloads.isEmpty()) {
/* in some very rare cases servers sends null status even though they should not */
if (statusViewData == null) {
showNotificationContent(false)
} else {
showNotificationContent(true)
val account = statusViewData.actionable.account
val createdAt = statusViewData.actionable.createdAt
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
setUsername(account.username)
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
if (viewData.type == Notification.Type.Status ||
viewData.type == Notification.Type.Update
) {
setAvatar(
account.avatar,
account.bot,
statusDisplayOptions.animateAvatars,
statusDisplayOptions.showBotOverlay
)
} else {
setAvatars(
account.avatar,
viewData.account.avatar,
statusDisplayOptions.animateAvatars
)
}
val viewThreadListener = View.OnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
statusActionListener.onViewThread(position)
}
}
binding.notificationContainer.setOnClickListener(viewThreadListener)
binding.notificationContent.setOnClickListener(viewThreadListener)
binding.notificationTopText.setOnClickListener {
statusActionListener.onViewAccount(viewData.account.id)
}
}
setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
} else {
for (item in payloads) {
if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
setCreatedAt(
statusViewData.status.actionableStatus.createdAt,
statusDisplayOptions.useAbsoluteTime
)
}
}
}
}
private fun showNotificationContent(show: Boolean) {
binding.statusDisplayName.visible(show)
binding.statusUsername.visible(show)
binding.statusMetaInfo.visible(show)
binding.notificationContentWarningDescription.visible(show)
binding.notificationContentWarningButton.visible(show)
binding.notificationContent.visible(show)
binding.notificationStatusAvatar.visible(show)
binding.notificationNotificationAvatar.visible(show)
binding.notificationAttachmentInfo.visible(show)
}
private fun setDisplayName(name: String, emojis: List<Emoji>, animateEmojis: Boolean) {
val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
binding.statusDisplayName.text = emojifiedName
}
private fun setUsername(name: String) {
val context = binding.statusUsername.context
val format = context.getString(R.string.post_username_format)
val usernameText = String.format(format, name)
binding.statusUsername.text = usernameText
}
private fun setCreatedAt(createdAt: Date, useAbsoluteTime: Boolean) {
if (useAbsoluteTime) {
binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
} else {
val readout: String // visible timestamp
val readoutAloud: CharSequence // for screenreaders so they don't mispronounce timestamps like "17m" as 17 meters
val then = createdAt.time
val now = System.currentTimeMillis()
readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
readoutAloud = DateUtils.getRelativeTimeSpanString(
then,
now,
DateUtils.SECOND_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
binding.statusMetaInfo.text = readout
binding.statusMetaInfo.contentDescription = readoutAloud
}
}
private fun getIconWithColor(
context: Context,
@DrawableRes drawable: Int,
@ColorRes color: Int
): Drawable? {
val icon = AppCompatResources.getDrawable(context, drawable)
icon?.setTint(context.getColor(color))
return icon
}
private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
loadAvatar(
statusAvatarUrl,
binding.notificationStatusAvatar,
avatarRadius48dp,
animateAvatars
)
if (showBotOverlay && isBot) {
binding.notificationNotificationAvatar.visibility = View.VISIBLE
Glide.with(binding.notificationNotificationAvatar)
.load(R.drawable.bot_badge)
.into(binding.notificationNotificationAvatar)
} else {
binding.notificationNotificationAvatar.visibility = View.GONE
}
}
private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
loadAvatar(
statusAvatarUrl,
binding.notificationStatusAvatar,
avatarRadius36dp,
animateAvatars
)
binding.notificationNotificationAvatar.visibility = View.VISIBLE
loadAvatar(
notificationAvatarUrl,
binding.notificationNotificationAvatar,
avatarRadius24dp,
animateAvatars
)
}
fun setMessage(
notificationViewData: NotificationViewData.Concrete,
listener: LinkListener,
animateEmojis: Boolean
) {
val statusViewData = notificationViewData.statusViewData
val displayName = notificationViewData.account.name.unicodeWrap()
val type = notificationViewData.type
val context = binding.notificationTopText.context
val format: String
val icon: Drawable?
when (type) {
Notification.Type.Favourite -> {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange_light)
format = context.getString(R.string.notification_favourite_format)
}
Notification.Type.Reblog -> {
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_orange)
format = context.getString(R.string.notification_reblog_format)
}
Notification.Type.Status -> {
icon = getIconWithColor(context, R.drawable.ic_notifications_active_24dp, R.color.chinwag_green)
format = context.getString(R.string.notification_subscription_format)
}
Notification.Type.Update -> {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green)
format = context.getString(R.string.notification_update_format)
}
else -> {
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange_light)
format = context.getString(R.string.notification_favourite_format)
}
}
binding.notificationTopText.setCompoundDrawablesRelativeWithIntrinsicBounds(
icon,
null,
null,
null
)
val wholeMessage = String.format(format, displayName).toSpannable()
val displayNameIndex = format.indexOf("%1\$s")
wholeMessage.setSpan(
StyleSpan(Typeface.BOLD),
displayNameIndex,
displayNameIndex + displayName.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
val emojifiedText = wholeMessage.emojify(
notificationViewData.account.emojis,
binding.notificationTopText,
animateEmojis
)
binding.notificationTopText.text = emojifiedText
if (statusViewData != null) {
val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty()
binding.notificationContentWarningDescription.visibility =
if (hasSpoiler) View.VISIBLE else View.GONE
binding.notificationContentWarningButton.visibility =
if (hasSpoiler) View.VISIBLE else View.GONE
if (statusViewData.isExpanded) {
binding.notificationContentWarningButton.setText(
R.string.post_content_warning_show_less
)
} else {
binding.notificationContentWarningButton.setText(
R.string.post_content_warning_show_more
)
}
binding.notificationContentWarningButton.setOnClickListener {
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
statusActionListener.onExpandedChange(
!statusViewData.isExpanded,
bindingAdapterPosition
)
}
binding.notificationContent.visibility =
if (statusViewData.isExpanded) View.GONE else View.VISIBLE
}
setupContentAndSpoiler(listener, statusViewData, animateEmojis)
}
}
private fun setupContentAndSpoiler(
listener: LinkListener,
statusViewData: StatusViewData.Concrete,
animateEmojis: Boolean
) {
val shouldShowContentIfSpoiler = statusViewData.isExpanded
val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty()
if (!shouldShowContentIfSpoiler && hasSpoiler) {
binding.notificationContent.visibility = View.GONE
} else {
binding.notificationContent.visibility = View.VISIBLE
}
val content = statusViewData.content
val emojis = statusViewData.actionable.emojis
if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
binding.buttonToggleNotificationContent.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
statusActionListener.onContentCollapsedChange(
!statusViewData.isCollapsed,
position
)
}
}
binding.buttonToggleNotificationContent.visibility = View.VISIBLE
if (statusViewData.isCollapsed) {
binding.buttonToggleNotificationContent.setText(
R.string.post_content_warning_show_more
)
binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
binding.notificationAttachmentInfo.hide()
} else {
binding.buttonToggleNotificationContent.setText(
R.string.post_content_warning_show_less
)
binding.notificationContent.filters = NO_INPUT_FILTER
setupAttachmentInfo(statusViewData.status)
}
} else {
binding.buttonToggleNotificationContent.visibility = View.GONE
binding.notificationContent.filters = NO_INPUT_FILTER
setupAttachmentInfo(statusViewData.status)
}
val emojifiedText = content.emojify(
emojis = emojis,
view = binding.notificationContent,
animate = animateEmojis
)
setClickableText(
binding.notificationContent,
emojifiedText,
statusViewData.actionable.mentions,
statusViewData.actionable.tags,
listener,
)
val emojifiedContentWarning: CharSequence = statusViewData.status.spoilerText.emojify(
statusViewData.actionable.emojis,
binding.notificationContentWarningDescription,
animateEmojis
)
binding.notificationContentWarningDescription.text = emojifiedContentWarning
}
private fun setupAttachmentInfo(status: Status) {
if (status.attachments.isNotEmpty()) {
binding.notificationAttachmentInfo.show()
binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_attach_file_24dp, 0, 0, 0)
val attachmentCount = status.attachments.size
val attachmentText = binding.root.context.resources.getQuantityString(R.plurals.media_attachments, attachmentCount, attachmentCount)
binding.notificationAttachmentInfo.text = attachmentText
} else if (status.poll != null) {
binding.notificationAttachmentInfo.show()
binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0)
binding.notificationAttachmentInfo.setText(R.string.poll)
} else {
binding.notificationAttachmentInfo.hide()
}
}
companion object {
private val COLLAPSE_INPUT_FILTER: Array<InputFilter> = arrayOf(SmartLengthInputFilter)
private val NO_INPUT_FILTER: Array<InputFilter> = arrayOf()
}
}

View file

@ -0,0 +1,92 @@
/*
* 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.View
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.NotificationViewData
internal class StatusViewHolder(
itemView: View,
private val statusActionListener: StatusActionListener,
private val accountId: String
) : NotificationsViewHolder, StatusViewHolder(itemView) {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
val statusViewData = viewData.statusViewData
if (statusViewData == null) {
/* in some very rare cases servers sends null status even though they should not */
showStatusContent(false)
} else {
if (payloads.isEmpty()) {
showStatusContent(true)
}
setupWithStatus(
statusViewData,
statusActionListener,
statusDisplayOptions,
payloads,
false
)
if (payloads.isNotEmpty()) {
return
}
val res = itemView.resources
if (viewData.type == Notification.Type.Poll) {
statusInfo.setText(if (accountId == viewData.account.id) R.string.poll_ended_created else R.string.poll_ended_voted)
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0)
statusInfo.setCompoundDrawablePadding(res.getDimensionPixelSize(R.dimen.status_info_drawable_padding_large))
statusInfo.setPadding(res.getDimensionPixelSize(R.dimen.status_info_padding_large), 0, 0, 0)
statusInfo.show()
} else if (viewData.type == Notification.Type.Mention) {
statusInfo.setCompoundDrawablePadding(res.getDimensionPixelSize(R.dimen.status_info_drawable_padding_small))
statusInfo.setPaddingRelative(res.getDimensionPixelSize(R.dimen.status_info_padding_small), 0, 0, 0)
statusInfo.show()
if (viewData.statusViewData.status.inReplyToAccountId == accountId) {
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_reply_18dp, 0, 0, 0)
if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) {
statusInfo.setText(R.string.notification_info_private_reply)
} else {
statusInfo.setText(R.string.notification_info_reply)
}
} else {
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_at_18dp, 0, 0, 0)
if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) {
statusInfo.setText(R.string.notification_info_private_mention)
} else {
statusInfo.setText(R.string.notification_info_mention)
}
}
} else {
hideStatusInfo()
}
}
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.notifications
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.NotificationViewData
internal class UnknownNotificationViewHolder(
private val binding: ItemUnknownNotificationBinding,
) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) {
override fun bind(
viewData: NotificationViewData.Concrete,
payloads: List<*>,
statusDisplayOptions: StatusDisplayOptions
) {
binding.unknownNotificationType.text = viewData.type.name
binding.root.setOnClickListener {
MaterialAlertDialogBuilder(binding.root.context)
.setMessage(R.string.unknown_notification_type_explanation)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}

View file

@ -0,0 +1,204 @@
/* Copyright 2024 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.requests
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.notifications.requests.details.NotificationRequestDetailsActivity
import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity
import com.keylesspalace.tusky.databinding.ActivityNotificationRequestsBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NotificationRequest
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.getErrorString
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.android.AndroidEntryPoint
import kotlin.String
import kotlin.getValue
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class NotificationRequestsActivity : BaseActivity(), MenuProvider {
private val viewModel: NotificationRequestsViewModel by viewModels()
private val binding by viewBinding(ActivityNotificationRequestsBinding::inflate)
private val notificationRequestDetails = registerForActivityResult(NotificationRequestDetailsResultContract()) { id ->
if (id != null) {
viewModel.removeNotificationRequest(id)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
addMenuProvider(this)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
setTitle(R.string.filtered_notifications_title)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
setupAdapter().let { adapter ->
setupRecyclerView(adapter)
lifecycleScope.launch {
viewModel.pager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
lifecycleScope.launch {
viewModel.error.collect { error ->
Snackbar.make(
binding.root,
error.getErrorString(this@NotificationRequestsActivity),
LENGTH_LONG
).show()
}
}
}
private fun setupRecyclerView(adapter: NotificationRequestsAdapter) {
binding.notificationRequestsView.adapter = adapter
binding.notificationRequestsView.setHasFixedSize(true)
binding.notificationRequestsView.layoutManager = LinearLayoutManager(this)
binding.notificationRequestsView.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
(binding.notificationRequestsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
}
private fun setupAdapter(): NotificationRequestsAdapter {
return NotificationRequestsAdapter(
onAcceptRequest = viewModel::acceptNotificationRequest,
onDismissRequest = viewModel::dismissNotificationRequest,
onOpenDetails = ::onOpenRequestDetails,
animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
).apply {
addLoadStateListener { loadState ->
binding.notificationRequestsProgressBar.visible(
loadState.refresh == LoadState.Loading && itemCount == 0
)
if (loadState.refresh is LoadState.Error) {
binding.notificationRequestsView.hide()
binding.notificationRequestsMessageView.show()
val errorState = loadState.refresh as LoadState.Error
binding.notificationRequestsMessageView.setup(errorState.error) { retry() }
Log.w(TAG, "error loading notification requests", errorState.error)
} else {
binding.notificationRequestsView.show()
binding.notificationRequestsMessageView.hide()
}
}
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_notification_requests, menu)
menu.findItem(R.id.open_settings)?.apply {
icon = IconicsDrawable(this@NotificationRequestsActivity, GoogleMaterial.Icon.gmd_settings).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.open_settings -> {
val intent = NotificationPoliciesActivity.newIntent(this)
startActivityWithSlideInAnimation(intent)
true
}
else -> false
}
}
private fun onOpenRequestDetails(reqeuest: NotificationRequest) {
notificationRequestDetails.launch(
NotificationRequestDetailsResultContractInput(
notificationRequestId = reqeuest.id,
accountId = reqeuest.account.id,
accountName = reqeuest.account.name,
accountEmojis = reqeuest.account.emojis
)
)
}
class NotificationRequestDetailsResultContractInput(
val notificationRequestId: String,
val accountId: String,
val accountName: String,
val accountEmojis: List<Emoji>
)
class NotificationRequestDetailsResultContract : ActivityResultContract<NotificationRequestDetailsResultContractInput, String?>() {
override fun createIntent(context: Context, input: NotificationRequestDetailsResultContractInput): Intent {
return NotificationRequestDetailsActivity.newIntent(
notificationRequestId = input.notificationRequestId,
accountId = input.accountId,
accountName = input.accountName,
accountEmojis = input.accountEmojis,
context = context
)
}
override fun parseResult(resultCode: Int, intent: Intent?): String? {
return intent?.getStringExtra(NotificationRequestDetailsActivity.EXTRA_NOTIFICATION_REQUEST_ID)
}
}
companion object {
private const val TAG = "NotificationRequestsActivity"
fun newIntent(context: Context) = Intent(context, NotificationRequestsActivity::class.java)
}
}

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