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:
parent
320900ce4e
commit
a66f7bb515
614 changed files with 52429 additions and 19916 deletions
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
201
app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt
Normal file
201
app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt
Normal 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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class FollowRequestsAdapter(
|
|||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
showHeader = false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!!
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue