Merge tag 'v25.2' into develop
# Conflicts: # README.md # app/build.gradle # app/lint-baseline.xml # app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt # app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt # app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt # app/src/main/res/layout/activity_about.xml # app/src/main/res/layout/item_emoji_pref.xml # app/src/main/res/values-ar/strings.xml # app/src/main/res/values-bg/strings.xml # app/src/main/res/values-cy/strings.xml # app/src/main/res/values-de/strings.xml # app/src/main/res/values-fa/strings.xml # app/src/main/res/values-gd/strings.xml # app/src/main/res/values-gl/strings.xml # app/src/main/res/values-hu/strings.xml # app/src/main/res/values-is/strings.xml # app/src/main/res/values-it/strings.xml # app/src/main/res/values-ja/strings.xml # app/src/main/res/values-nl/strings.xml # app/src/main/res/values-oc/strings.xml # app/src/main/res/values-pt-rBR/strings.xml # app/src/main/res/values-pt-rPT/strings.xml # app/src/main/res/values-ru/strings.xml # app/src/main/res/values-si/strings.xml # app/src/main/res/values-sv/strings.xml # app/src/main/res/values-tr/strings.xml # app/src/main/res/values-uk/strings.xml # app/src/main/res/values-vi/strings.xml # app/src/main/res/values-zh-rCN/strings.xml # app/src/main/res/values/strings.xml # fastlane/metadata/android/ru/full_description.txt # fastlane/metadata/android/zh-Hans/full_description.txt
This commit is contained in:
parent
84670dbc0b
commit
875013e47f
630 changed files with 22153 additions and 18732 deletions
|
|
@ -1,6 +1,10 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
|
|
@ -8,13 +12,22 @@ import android.text.method.LinkMovementMethod
|
|||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.databinding.ActivityAboutBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.util.NoUnderlineURLSpan
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AboutActivity : BottomSheetActivity(), Injectable {
|
||||
@Inject
|
||||
lateinit var instanceInfoRepository: InstanceInfoRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -32,13 +45,41 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
|
||||
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
|
||||
|
||||
binding.deviceInfo.text = getString(
|
||||
R.string.about_device_info,
|
||||
Build.MANUFACTURER,
|
||||
Build.MODEL,
|
||||
Build.VERSION.RELEASE,
|
||||
Build.VERSION.SDK_INT
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
accountManager.activeAccount?.let { account ->
|
||||
val instanceInfo = instanceInfoRepository.getUpdatedInstanceInfoOrFallback()
|
||||
binding.accountInfo.text = getString(
|
||||
R.string.about_account_info,
|
||||
account.username,
|
||||
account.domain,
|
||||
instanceInfo.version
|
||||
)
|
||||
binding.accountInfoTitle.show()
|
||||
binding.accountInfo.show()
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.CUSTOM_INSTANCE.isBlank()) {
|
||||
binding.aboutPoweredByTusky.hide()
|
||||
}
|
||||
|
||||
binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(R.string.about_tusky_license)
|
||||
binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(R.string.about_project_site)
|
||||
binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(R.string.about_bug_feature_request_site)
|
||||
binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(
|
||||
R.string.about_tusky_license
|
||||
)
|
||||
binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(
|
||||
R.string.about_project_site
|
||||
)
|
||||
binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(
|
||||
R.string.about_bug_feature_request_site
|
||||
)
|
||||
|
||||
binding.tuskyProfileButton.setOnClickListener {
|
||||
viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL)
|
||||
|
|
@ -47,6 +88,16 @@ class AboutActivity : BottomSheetActivity(), Injectable {
|
|||
binding.aboutLicensesButton.setOnClickListener {
|
||||
startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java))
|
||||
}
|
||||
|
||||
binding.copyDeviceInfo.setOnClickListener {
|
||||
val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}"
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Tusky version information", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ import com.keylesspalace.tusky.util.unsafeLazy
|
|||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.State
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
|
||||
|
||||
|
|
@ -82,11 +82,18 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
super.onStart()
|
||||
dialog?.apply {
|
||||
// Stretch dialog to the window
|
||||
window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_accounts_in_list, container, false)
|
||||
}
|
||||
|
||||
|
|
@ -164,15 +171,27 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: TimelineAccount,
|
||||
newItem: TimelineAccount
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) {
|
||||
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(
|
||||
AccountDiffer
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val holder = BindingHolder(binding)
|
||||
|
||||
binding.notificationTextView.hide()
|
||||
|
|
@ -186,7 +205,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemFollowRequestBinding>, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
holder: BindingHolder<ItemFollowRequestBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val account = getItem(position)
|
||||
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
|
||||
holder.binding.usernameTextView.text = account.username
|
||||
|
|
@ -204,10 +226,19 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
inner class SearchAdapter : ListAdapter<AccountInfo, BindingHolder<ItemFollowRequestBinding>>(SearchDiffer) {
|
||||
inner class SearchAdapter : ListAdapter<AccountInfo, BindingHolder<ItemFollowRequestBinding>>(
|
||||
SearchDiffer
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemFollowRequestBinding> {
|
||||
val binding = ItemFollowRequestBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val holder = BindingHolder(binding)
|
||||
|
||||
binding.notificationTextView.hide()
|
||||
|
|
@ -224,7 +255,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemFollowRequestBinding>, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
holder: BindingHolder<ItemFollowRequestBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val (account, inAList) = getItem(position)
|
||||
|
||||
holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ 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;
|
||||
|
|
@ -56,10 +58,17 @@ 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";
|
||||
|
||||
@Inject
|
||||
@NonNull
|
||||
public AccountManager accountManager;
|
||||
|
||||
private static final int REQUESTER_NONE = Integer.MAX_VALUE;
|
||||
|
|
@ -69,14 +78,19 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
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);
|
||||
}
|
||||
|
||||
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("appTheme", ThemeUtils.APP_THEME_DEFAULT);
|
||||
String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue());
|
||||
Log.d("activeTheme", theme);
|
||||
if (theme.equals("black")) {
|
||||
if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) {
|
||||
setTheme(R.style.TuskyBlackTheme);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +101,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
|
||||
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
|
||||
|
||||
int style = textStyle(preferences.getString("statusTextSize", "medium"));
|
||||
int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"));
|
||||
getTheme().applyStyle(style, true);
|
||||
|
||||
if(requiresLogin()) {
|
||||
|
|
@ -97,6 +111,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
requesters = new HashMap<>();
|
||||
}
|
||||
|
||||
private boolean activityTransitionWasRequested() {
|
||||
return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context newBase) {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
|
||||
|
|
@ -162,13 +180,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
return style;
|
||||
}
|
||||
|
||||
public void startActivityWithSlideInAnimation(Intent intent) {
|
||||
super.startActivity(intent);
|
||||
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
return true;
|
||||
|
|
@ -179,11 +192,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
@Override
|
||||
public void finish() {
|
||||
super.finish();
|
||||
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right);
|
||||
}
|
||||
|
||||
public void finishWithoutSlideOutAnimation() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
protected void redirectIfNotLoggedIn() {
|
||||
|
|
@ -191,12 +203,12 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
if (account == null) {
|
||||
Intent intent = new Intent(this, LoginActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivityWithSlideInAnimation(intent);
|
||||
ActivityExtensions.startActivityWithSlideInAnimation(this, intent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) {
|
||||
protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) {
|
||||
if (anyView != null) {
|
||||
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);
|
||||
bar.setAction(actionId, listener);
|
||||
|
|
@ -204,7 +216,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
}
|
||||
|
||||
public void showAccountChooserDialog(CharSequence dialogTitle, boolean showActiveAccount, AccountSelectionListener listener) {
|
||||
public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) {
|
||||
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
|
||||
AccountEntity activeAccount = accountManager.getActiveAccount();
|
||||
|
||||
|
|
@ -231,9 +243,9 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
adapter.addAll(accounts);
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(dialogTitle)
|
||||
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
|
||||
.show();
|
||||
.setTitle(dialogTitle)
|
||||
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
|
||||
.show();
|
||||
}
|
||||
|
||||
public @Nullable String getOpenAsText() {
|
||||
|
|
@ -256,11 +268,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
|
||||
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
|
||||
accountManager.setActiveAccount(account.getId());
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
intent.putExtra(MainActivity.REDIRECT_URL, url);
|
||||
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
|
||||
|
||||
startActivity(intent);
|
||||
finishWithoutSlideOutAnimation();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -272,7 +283,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
}
|
||||
|
||||
public void requestPermissions(String[] permissions, PermissionRequester requester) {
|
||||
public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) {
|
||||
ArrayList<String> permissionsToRequest = new ArrayList<>();
|
||||
for(String permission: permissions) {
|
||||
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
|
|
|||
|
|
@ -22,17 +22,17 @@ import android.view.View
|
|||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
||||
import autodispose2.autoDispose
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.looksLikeMastodonUrl
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** this is the base class for all activities that open links
|
||||
* links are checked against the api if they are mastodon links so they can be opened in Tusky
|
||||
|
|
@ -64,45 +64,48 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
})
|
||||
}
|
||||
|
||||
open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) {
|
||||
open fun viewUrl(
|
||||
url: String,
|
||||
lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER
|
||||
) {
|
||||
if (!looksLikeMastodonUrl(url)) {
|
||||
openLink(url)
|
||||
return
|
||||
}
|
||||
|
||||
mastodonApi.searchObservable(
|
||||
query = url,
|
||||
resolve = true
|
||||
).observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{ (accounts, statuses) ->
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.search(
|
||||
query = url,
|
||||
resolve = true
|
||||
).fold(
|
||||
onSuccess = { (accounts, statuses) ->
|
||||
if (getCancelSearchRequested(url)) {
|
||||
return@subscribe
|
||||
return@launch
|
||||
}
|
||||
|
||||
onEndSearch(url)
|
||||
|
||||
if (statuses.isNotEmpty()) {
|
||||
viewThread(statuses[0].id, statuses[0].url)
|
||||
return@subscribe
|
||||
return@launch
|
||||
}
|
||||
accounts.firstOrNull { it.url == url }?.let { account ->
|
||||
accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account ->
|
||||
// Some servers return (unrelated) accounts for url searches (#2804)
|
||||
// Verify that the account's url matches the query
|
||||
viewAccount(account.id)
|
||||
return@subscribe
|
||||
return@launch
|
||||
}
|
||||
|
||||
performUrlFallbackAction(url, lookupFallbackBehavior)
|
||||
},
|
||||
{
|
||||
onFailure = {
|
||||
if (!getCancelSearchRequested(url)) {
|
||||
onEndSearch(url)
|
||||
performUrlFallbackAction(url, lookupFallbackBehavior)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onBeginSearch(url)
|
||||
}
|
||||
|
|
@ -121,10 +124,17 @@ abstract class BottomSheetActivity : BaseActivity() {
|
|||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
|
||||
protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) {
|
||||
protected open fun performUrlFallbackAction(
|
||||
url: String,
|
||||
fallbackBehavior: PostLookupFallbackBehavior
|
||||
) {
|
||||
when (fallbackBehavior) {
|
||||
PostLookupFallbackBehavior.OPEN_IN_BROWSER -> openLink(url)
|
||||
PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(this, getString(R.string.post_lookup_error_format, url), Toast.LENGTH_SHORT).show()
|
||||
PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(
|
||||
this,
|
||||
getString(R.string.post_lookup_error_format, url),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.bumptech.glide.Glide
|
||||
|
|
@ -46,15 +48,18 @@ import com.keylesspalace.tusky.di.ViewModelFactory
|
|||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.await
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ProfileDataInUi
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EditProfileActivity : BaseActivity(), Injectable {
|
||||
|
||||
|
|
@ -96,6 +101,14 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
private val currentProfileData
|
||||
get() = ProfileDataInUi(
|
||||
displayName = binding.displayNameEditText.text.toString(),
|
||||
note = binding.noteEditText.text.toString(),
|
||||
locked = binding.lockedCheckBox.isChecked,
|
||||
fields = accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
|
@ -114,9 +127,17 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
binding.fieldList.layoutManager = LinearLayoutManager(this)
|
||||
binding.fieldList.adapter = accountFieldEditAdapter
|
||||
|
||||
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply { sizeDp = 12; colorInt = Color.WHITE }
|
||||
val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply {
|
||||
sizeDp = 12
|
||||
colorInt = Color.WHITE
|
||||
}
|
||||
|
||||
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(plusDrawable, null, null, null)
|
||||
binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
plusDrawable,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
binding.addFieldButton.setOnClickListener {
|
||||
accountFieldEditAdapter.addField()
|
||||
|
|
@ -131,52 +152,64 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
|
||||
viewModel.obtainProfile()
|
||||
|
||||
viewModel.profileData.observe(this) { profileRes ->
|
||||
when (profileRes) {
|
||||
is Success -> {
|
||||
val me = profileRes.data
|
||||
if (me != null) {
|
||||
binding.displayNameEditText.setText(me.displayName)
|
||||
binding.noteEditText.setText(me.source?.note)
|
||||
binding.lockedCheckBox.isChecked = me.locked
|
||||
lifecycleScope.launch {
|
||||
viewModel.profileData.collect { profileRes ->
|
||||
if (profileRes == null) return@collect
|
||||
when (profileRes) {
|
||||
is Success -> {
|
||||
val me = profileRes.data
|
||||
if (me != null) {
|
||||
binding.displayNameEditText.setText(me.displayName)
|
||||
binding.noteEditText.setText(me.source?.note)
|
||||
binding.lockedCheckBox.isChecked = me.locked
|
||||
|
||||
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
|
||||
binding.addFieldButton.isVisible =
|
||||
(me.source?.fields?.size ?: 0) < maxAccountFields
|
||||
accountFieldEditAdapter.setFields(me.source?.fields.orEmpty())
|
||||
binding.addFieldButton.isVisible =
|
||||
(me.source?.fields?.size ?: 0) < maxAccountFields
|
||||
|
||||
if (viewModel.avatarData.value == null) {
|
||||
Glide.with(this)
|
||||
.load(me.avatar)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
|
||||
)
|
||||
.into(binding.avatarPreview)
|
||||
}
|
||||
if (viewModel.avatarData.value == null) {
|
||||
Glide.with(this@EditProfileActivity)
|
||||
.load(me.avatar)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(
|
||||
resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)
|
||||
)
|
||||
)
|
||||
.into(binding.avatarPreview)
|
||||
}
|
||||
|
||||
if (viewModel.headerData.value == null) {
|
||||
Glide.with(this)
|
||||
.load(me.header)
|
||||
.into(binding.headerPreview)
|
||||
if (viewModel.headerData.value == null) {
|
||||
Glide.with(this@EditProfileActivity)
|
||||
.load(me.header)
|
||||
.into(binding.headerPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Error -> {
|
||||
Snackbar.make(
|
||||
binding.avatarButton,
|
||||
R.string.error_generic,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.obtainProfile()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
is Error -> {
|
||||
Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.obtainProfile()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.instanceData.collect { instanceInfo ->
|
||||
maxAccountFields = instanceInfo.maxFields
|
||||
accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength)
|
||||
accountFieldEditAdapter.setFieldLimits(
|
||||
instanceInfo.maxFieldNameLength,
|
||||
instanceInfo.maxFieldValueLength
|
||||
)
|
||||
binding.addFieldButton.isVisible =
|
||||
accountFieldEditAdapter.itemCount < maxAccountFields
|
||||
}
|
||||
|
|
@ -185,60 +218,85 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
observeImage(viewModel.avatarData, binding.avatarPreview, true)
|
||||
observeImage(viewModel.headerData, binding.headerPreview, false)
|
||||
|
||||
viewModel.saveData.observe(
|
||||
this
|
||||
) {
|
||||
when (it) {
|
||||
is Success -> {
|
||||
finish()
|
||||
}
|
||||
is Loading -> {
|
||||
binding.saveProgressBar.visibility = View.VISIBLE
|
||||
}
|
||||
is Error -> {
|
||||
onSaveFailure(it.errorMessage)
|
||||
lifecycleScope.launch {
|
||||
viewModel.saveData.collect {
|
||||
if (it == null) return@collect
|
||||
when (it) {
|
||||
is Success -> {
|
||||
finish()
|
||||
}
|
||||
is Loading -> {
|
||||
binding.saveProgressBar.visibility = View.VISIBLE
|
||||
}
|
||||
is Error -> {
|
||||
onSaveFailure(it.errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.displayNameEditText.doAfterTextChanged {
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
binding.displayNameEditText.doAfterTextChanged {
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
binding.lockedCheckBox.setOnCheckedChangeListener { _, _ ->
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
accountFieldEditAdapter.onFieldsChanged = {
|
||||
viewModel.dataChanged(currentProfileData)
|
||||
}
|
||||
|
||||
val onBackCallback = object : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
showUnsavedChangesDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, onBackCallback)
|
||||
lifecycleScope.launch {
|
||||
viewModel.isChanged.collect { dataWasChanged ->
|
||||
onBackCallback.isEnabled = dataWasChanged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!isFinishing) {
|
||||
viewModel.updateProfile(
|
||||
binding.displayNameEditText.text.toString(),
|
||||
binding.noteEditText.text.toString(),
|
||||
binding.lockedCheckBox.isChecked,
|
||||
accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
viewModel.updateProfile(currentProfileData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeImage(
|
||||
liveData: LiveData<Uri>,
|
||||
flow: StateFlow<Uri?>,
|
||||
imageView: ImageView,
|
||||
roundedCorners: Boolean
|
||||
) {
|
||||
liveData.observe(
|
||||
this
|
||||
) { imageUri ->
|
||||
lifecycleScope.launch {
|
||||
flow.collect { imageUri ->
|
||||
|
||||
// skipping all caches so we can always reuse the same uri
|
||||
val glide = Glide.with(imageView)
|
||||
.load(imageUri)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
// skipping all caches so we can always reuse the same uri
|
||||
val glide = Glide.with(imageView)
|
||||
.load(imageUri)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
|
||||
if (roundedCorners) {
|
||||
glide.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
|
||||
).into(imageView)
|
||||
} else {
|
||||
glide.into(imageView)
|
||||
if (roundedCorners) {
|
||||
glide.transform(
|
||||
FitCenter(),
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
|
||||
).into(imageView)
|
||||
} else {
|
||||
glide.into(imageView)
|
||||
}
|
||||
|
||||
imageView.show()
|
||||
}
|
||||
|
||||
imageView.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,14 +345,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
viewModel.save(
|
||||
binding.displayNameEditText.text.toString(),
|
||||
binding.noteEditText.text.toString(),
|
||||
binding.lockedCheckBox.isChecked,
|
||||
accountFieldEditAdapter.getFieldData()
|
||||
)
|
||||
}
|
||||
private fun save() = viewModel.save(currentProfileData)
|
||||
|
||||
private fun onSaveFailure(msg: String?) {
|
||||
val errorMsg = msg ?: getString(R.string.error_media_upload_sending)
|
||||
|
|
@ -304,6 +355,22 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
|
||||
private fun onPickFailure(throwable: Throwable?) {
|
||||
Log.w("EditProfileActivity", "failed to pick media", throwable)
|
||||
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
|
||||
Snackbar.make(
|
||||
binding.avatarButton,
|
||||
R.string.error_media_upload_sending,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesDialog() = lifecycleScope.launch {
|
||||
when (launchSaveDialog()) {
|
||||
AlertDialog.BUTTON_POSITIVE -> save()
|
||||
else -> finish()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun launchSaveDialog() = AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.dialog_save_profile_changes_message))
|
||||
.create()
|
||||
.await(R.string.action_save, R.string.action_discard)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ import android.os.Bundle
|
|||
import android.util.Log
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.RawRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
|
||||
import com.keylesspalace.tusky.util.closeQuietly
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
||||
class LicenseActivity : BaseActivity() {
|
||||
|
||||
|
|
@ -44,23 +47,15 @@ class LicenseActivity : BaseActivity() {
|
|||
}
|
||||
|
||||
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
|
||||
val sb = StringBuilder()
|
||||
|
||||
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
|
||||
|
||||
try {
|
||||
var line: String? = br.readLine()
|
||||
while (line != null) {
|
||||
sb.append(line)
|
||||
sb.append('\n')
|
||||
line = br.readLine()
|
||||
lifecycleScope.launch {
|
||||
textView.text = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
resources.openRawResource(fileId).source().buffer().use { it.readUtf8() }
|
||||
} catch (e: IOException) {
|
||||
Log.w("LicenseActivity", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w("LicenseActivity", e)
|
||||
}
|
||||
|
||||
br.closeQuietly()
|
||||
|
||||
textView.text = sb.toString()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
/* Copyright Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
|
|
@ -23,11 +23,7 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
|
@ -37,16 +33,17 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.databinding.ActivityListsBinding
|
||||
import com.keylesspalace.tusky.databinding.DialogListBinding
|
||||
import com.keylesspalace.tusky.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.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||
|
|
@ -56,18 +53,12 @@ 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 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 kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Created by charlag on 1/4/18.
|
||||
*/
|
||||
// TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?)
|
||||
|
||||
class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
||||
|
||||
|
|
@ -118,7 +109,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||
Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
|
||||
Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list)
|
||||
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
|
||||
}
|
||||
}
|
||||
|
|
@ -126,16 +117,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
}
|
||||
|
||||
private fun showlistNameDialog(list: MastoList?) {
|
||||
val layout = FrameLayout(this)
|
||||
val editText = EditText(this)
|
||||
editText.setHint(R.string.hint_list_name)
|
||||
layout.addView(editText)
|
||||
val margin = Utils.dpToPx(this, 8)
|
||||
(editText.layoutParams as ViewGroup.MarginLayoutParams)
|
||||
.setMargins(margin, margin, margin, 0)
|
||||
|
||||
val binding = DialogListBinding.inflate(layoutInflater).apply {
|
||||
replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal)
|
||||
}
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(layout)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(
|
||||
if (list == null) {
|
||||
R.string.action_create_list
|
||||
|
|
@ -143,17 +129,31 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
R.string.action_rename_list
|
||||
}
|
||||
) { _, _ ->
|
||||
onPickedDialogName(editText.text, list?.id)
|
||||
onPickedDialogName(
|
||||
binding.nameText.text.toString(),
|
||||
list?.id,
|
||||
binding.exclusiveCheckbox.isChecked,
|
||||
MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy
|
||||
)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
positiveButton.isEnabled = s?.isNotBlank() == true
|
||||
binding.nameText.let { editText ->
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true
|
||||
}
|
||||
editText.setText(list?.title)
|
||||
editText.text?.let { editText.setSelection(it.length) }
|
||||
}
|
||||
|
||||
list?.let {
|
||||
if (it.exclusive == null) {
|
||||
binding.exclusiveCheckbox.visible(false)
|
||||
} else {
|
||||
binding.exclusiveCheckbox.isChecked = it.exclusive
|
||||
}
|
||||
}
|
||||
editText.setText(list?.title)
|
||||
editText.text?.let { editText.setSelection(it.length) }
|
||||
}
|
||||
|
||||
private fun showListDeleteDialog(list: MastoList) {
|
||||
|
|
@ -174,13 +174,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
INITIAL, LOADING -> binding.messageView.hide()
|
||||
ERROR_NETWORK -> {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
|
||||
viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
ERROR_OTHER -> {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
|
||||
viewModel.retryLoading()
|
||||
}
|
||||
}
|
||||
|
|
@ -192,6 +192,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
binding.messageView.showHelp(R.string.help_empty_lists)
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
}
|
||||
|
|
@ -206,9 +207,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
).show()
|
||||
}
|
||||
|
||||
private fun onListSelected(listId: String, listTitle: String) {
|
||||
private fun onListSelected(list: MastoList) {
|
||||
startActivityWithSlideInAnimation(
|
||||
StatusListActivity.newListIntent(this, listId, listTitle)
|
||||
StatusListActivity.newListIntent(this, list.id, list.title)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +227,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.list_edit -> openListSettings(list)
|
||||
R.id.list_rename -> renameListDialog(list)
|
||||
R.id.list_update -> renameListDialog(list)
|
||||
R.id.list_delete -> showListDeleteDialog(list)
|
||||
else -> return@setOnMenuItemClickListener false
|
||||
}
|
||||
|
|
@ -247,51 +248,42 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
}
|
||||
|
||||
private inner class ListsAdapter :
|
||||
ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
|
||||
ListAdapter<MastoList, BindingHolder<ItemListBinding>>(ListsDiffer) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
|
||||
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
|
||||
.let(this::ListViewHolder)
|
||||
.apply {
|
||||
val iconColor = MaterialColors.getColor(nameTextView, android.R.attr.textColorTertiary)
|
||||
val context = nameTextView.context
|
||||
val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemListBinding> {
|
||||
return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemListBinding>, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.binding.listName.text = item.title
|
||||
|
||||
holder.binding.moreButton.apply {
|
||||
visible(true)
|
||||
setOnClickListener {
|
||||
onMore(item, holder.binding.moreButton)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
|
||||
holder.nameTextView.text = getItem(position).title
|
||||
}
|
||||
|
||||
private inner class ListViewHolder(view: View) :
|
||||
RecyclerView.ViewHolder(view),
|
||||
View.OnClickListener {
|
||||
val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
|
||||
val moreButton: ImageButton = view.findViewById(R.id.editListButton)
|
||||
|
||||
init {
|
||||
view.setOnClickListener(this)
|
||||
moreButton.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
if (v == itemView) {
|
||||
val list = getItem(bindingAdapterPosition)
|
||||
onListSelected(list.id, list.title)
|
||||
} else {
|
||||
onMore(getItem(bindingAdapterPosition), v)
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
onListSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPickedDialogName(name: CharSequence, listId: String?) {
|
||||
private fun onPickedDialogName(
|
||||
name: String,
|
||||
listId: String?,
|
||||
exclusive: Boolean,
|
||||
replyPolicy: String
|
||||
) {
|
||||
if (listId == null) {
|
||||
viewModel.createNewList(name.toString())
|
||||
viewModel.createNewList(name, exclusive, replyPolicy)
|
||||
} else {
|
||||
viewModel.renameList(listId, name.toString())
|
||||
viewModel.updateList(listId, name, exclusive, replyPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
|
|
@ -26,6 +28,7 @@ import android.graphics.drawable.Animatable
|
|||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
|
|
@ -33,6 +36,7 @@ import android.view.KeyEvent
|
|||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.MenuItem.SHOW_AS_ACTION_NEVER
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
|
|
@ -41,15 +45,18 @@ import androidx.appcompat.content.res.AppCompatResources
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.forEach
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.target.FixedSizeDrawable
|
||||
|
|
@ -60,8 +67,11 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
|
|||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||
import com.keylesspalace.tusky.appstore.CacheUpdater
|
||||
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
|
||||
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
|
|
@ -81,8 +91,10 @@ import com.keylesspalace.tusky.components.trending.TrendingActivity
|
|||
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.FabFragment
|
||||
|
|
@ -91,16 +103,17 @@ import com.keylesspalace.tusky.pager.MainPagerAdapter
|
|||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
|
||||
import com.keylesspalace.tusky.usecase.LogoutUsecase
|
||||
import com.keylesspalace.tusky.util.ShareShortcutHelper
|
||||
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getDimension
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.updateShortcut
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
|
|
@ -131,9 +144,10 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
|
|||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider {
|
||||
@Inject
|
||||
|
|
@ -154,21 +168,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
@Inject
|
||||
lateinit var developerToolsUseCase: DeveloperToolsUseCase
|
||||
|
||||
@Inject
|
||||
lateinit var shareShortcutHelper: ShareShortcutHelper
|
||||
|
||||
@Inject
|
||||
@ApplicationScope
|
||||
lateinit var externalScope: CoroutineScope
|
||||
|
||||
private val binding by viewBinding(ActivityMainBinding::inflate)
|
||||
|
||||
private lateinit var header: AccountHeaderView
|
||||
|
||||
private var notificationTabPosition = 0
|
||||
private var onTabSelectedListener: OnTabSelectedListener? = null
|
||||
|
||||
private var unreadAnnouncementsCount = 0
|
||||
|
||||
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
private lateinit var glide: RequestManager
|
||||
|
||||
private var accountLocked: Boolean = false
|
||||
|
||||
// We need to know if the emoji pack has been changed
|
||||
private var selectedEmojiPack: String? = null
|
||||
|
||||
|
|
@ -178,37 +194,68 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
/** Adapter for the different timeline tabs */
|
||||
private lateinit var tabAdapter: MainPagerAdapter
|
||||
|
||||
private var directMessageTab: TabLayout.Tab? = null
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
when {
|
||||
binding.mainDrawerLayout.isOpen -> {
|
||||
binding.mainDrawerLayout.close()
|
||||
}
|
||||
binding.viewPager.currentItem != 0 -> {
|
||||
binding.viewPager.currentItem = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val activeAccount = accountManager.activeAccount
|
||||
?: return // will be redirected to LoginActivity by BaseActivity
|
||||
|
||||
if (supportsOverridingActivityTransitions() && explodeAnimationWasRequested()) {
|
||||
overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.explode, R.anim.activity_open_exit)
|
||||
}
|
||||
|
||||
var showNotificationTab = false
|
||||
if (intent != null) {
|
||||
|
||||
// check for savedInstanceState in order to not handle intent events more than once
|
||||
if (intent != null && savedInstanceState == null) {
|
||||
val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1)
|
||||
if (notificationId != -1) {
|
||||
// opened from a notification action, cancel the notification
|
||||
val notificationManager = getSystemService(
|
||||
NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId)
|
||||
}
|
||||
|
||||
/** there are two possibilities the accountId can be passed to MainActivity:
|
||||
* - from our code as long 'account_id'
|
||||
* - from our code as Long Intent Extra TUSKY_ACCOUNT_ID
|
||||
* - from share shortcuts as String 'android.intent.extra.shortcut.ID'
|
||||
*/
|
||||
var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1)
|
||||
if (accountId == -1L) {
|
||||
var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1)
|
||||
if (tuskyAccountId == -1L) {
|
||||
val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
|
||||
if (accountIdString != null) {
|
||||
accountId = accountIdString.toLong()
|
||||
tuskyAccountId = accountIdString.toLong()
|
||||
}
|
||||
}
|
||||
val accountRequested = accountId != -1L
|
||||
if (accountRequested && accountId != activeAccount.id) {
|
||||
accountManager.setActiveAccount(accountId)
|
||||
val accountRequested = tuskyAccountId != -1L
|
||||
if (accountRequested && tuskyAccountId != activeAccount.id) {
|
||||
accountManager.setActiveAccount(tuskyAccountId)
|
||||
}
|
||||
|
||||
val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
|
||||
|
||||
if (canHandleMimeType(intent.type)) {
|
||||
if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) {
|
||||
// Sharing to Tusky from an external app
|
||||
if (accountRequested) {
|
||||
// The correct account is already active
|
||||
forwardShare(intent)
|
||||
forwardToComposeActivity(intent)
|
||||
} else {
|
||||
// No account was provided, show the chooser
|
||||
showAccountChooserDialog(
|
||||
|
|
@ -219,10 +266,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
val requestedId = account.id
|
||||
if (requestedId == activeAccount.id) {
|
||||
// The correct account is already active
|
||||
forwardShare(intent)
|
||||
forwardToComposeActivity(intent)
|
||||
} else {
|
||||
// A different account was requested, restart the activity
|
||||
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
|
||||
intent.putExtra(TUSKY_ACCOUNT_ID, requestedId)
|
||||
changeAccount(requestedId, intent)
|
||||
}
|
||||
}
|
||||
|
|
@ -232,11 +279,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
} else if (openDrafts) {
|
||||
val intent = DraftsActivity.newIntent(this)
|
||||
startActivity(intent)
|
||||
} else if (accountRequested && savedInstanceState == null) {
|
||||
} else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) {
|
||||
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
|
||||
// otherwise show notification tab
|
||||
if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
|
||||
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true)
|
||||
if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
|
||||
val intent = AccountListActivity.newIntent(
|
||||
this,
|
||||
AccountListActivity.Type.FOLLOW_REQUESTS
|
||||
)
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
} else {
|
||||
showNotificationTab = true
|
||||
|
|
@ -245,17 +295,27 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.mainToolbar)
|
||||
|
||||
glide = Glide.with(this)
|
||||
|
||||
binding.composeButton.setOnClickListener {
|
||||
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
|
||||
startActivity(composeIntent)
|
||||
}
|
||||
|
||||
// Determine which of the three toolbars should be the supportActionBar (which hosts
|
||||
// the options menu).
|
||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||
binding.mainToolbar.visible(!hideTopToolbar)
|
||||
if (hideTopToolbar) {
|
||||
when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) {
|
||||
"top" -> setSupportActionBar(binding.topNav)
|
||||
"bottom" -> setSupportActionBar(binding.bottomNav)
|
||||
}
|
||||
binding.mainToolbar.hide()
|
||||
// There's not enough space in the top/bottom bars to show the title as well.
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
} else {
|
||||
setSupportActionBar(binding.mainToolbar)
|
||||
binding.mainToolbar.show()
|
||||
}
|
||||
|
||||
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
|
||||
|
||||
|
|
@ -266,7 +326,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
setupDrawer(
|
||||
savedInstanceState,
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING)
|
||||
addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(
|
||||
TRENDING_TAGS
|
||||
),
|
||||
addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab(
|
||||
TRENDING_STATUSES
|
||||
)
|
||||
)
|
||||
|
||||
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
||||
|
|
@ -291,47 +356,57 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
is MainTabsChangedEvent -> {
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = hideTopToolbar,
|
||||
addTrendingButton = !event.newTabs.hasTab(TRENDING)
|
||||
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS),
|
||||
addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES)
|
||||
)
|
||||
|
||||
setupTabs(false)
|
||||
}
|
||||
|
||||
is AnnouncementReadEvent -> {
|
||||
unreadAnnouncementsCount--
|
||||
updateAnnouncementsBadge()
|
||||
}
|
||||
is NewNotificationsEvent -> {
|
||||
directMessageTab?.let {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
val hasDirectMessageNotification =
|
||||
event.notifications.any {
|
||||
it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT
|
||||
}
|
||||
|
||||
if (hasDirectMessageNotification) {
|
||||
showDirectMessageBadge(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is NotificationsLoadingEvent -> {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
showDirectMessageBadge(false)
|
||||
}
|
||||
}
|
||||
is ConversationsLoadingEvent -> {
|
||||
if (event.accountId == activeAccount.accountId) {
|
||||
showDirectMessageBadge(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Schedulers.io().scheduleDirect {
|
||||
externalScope.launch(Dispatchers.IO) {
|
||||
// Flush old media that was cached for sharing
|
||||
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
|
||||
}
|
||||
|
||||
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
when {
|
||||
binding.mainDrawerLayout.isOpen -> {
|
||||
binding.mainDrawerLayout.close()
|
||||
}
|
||||
binding.viewPager.currentItem != 0 -> {
|
||||
binding.viewPager.currentItem = 0
|
||||
}
|
||||
else -> {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
|
|
@ -343,6 +418,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
draftsAlert.observeInContext(this, true)
|
||||
}
|
||||
|
||||
private fun showDirectMessageBadge(showBadge: Boolean) {
|
||||
directMessageTab?.let { tab ->
|
||||
tab.badge?.isVisible = showBadge
|
||||
|
||||
// TODO a bit cumbersome (also for resetting)
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
accountManager.activeAccount?.let {
|
||||
if (it.hasDirectMessageBadge != showBadge) {
|
||||
it.hasDirectMessageBadge = showBadge
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.activity_main, menu)
|
||||
menu.findItem(R.id.action_search)?.apply {
|
||||
|
|
@ -353,6 +444,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
|
||||
// If the main toolbar is hidden then there's no space in the top/bottomNav to show
|
||||
// the menu items as icons, so forceably disable them
|
||||
if (!binding.mainToolbar.isVisible) {
|
||||
menu.forEach {
|
||||
it.setShowAsAction(
|
||||
SHOW_AS_ACTION_NEVER
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_search -> {
|
||||
|
|
@ -425,12 +530,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
private fun forwardShare(intent: Intent) {
|
||||
val composeIntent = Intent(this, ComposeActivity::class.java)
|
||||
composeIntent.action = intent.action
|
||||
composeIntent.type = intent.type
|
||||
composeIntent.putExtras(intent)
|
||||
composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
private fun forwardToComposeActivity(intent: Intent) {
|
||||
val composeOptions = IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
COMPOSE_OPTIONS,
|
||||
ComposeActivity.ComposeOptions::class.java
|
||||
)
|
||||
|
||||
val composeIntent = if (composeOptions != null) {
|
||||
ComposeActivity.startIntent(this, composeOptions)
|
||||
} else {
|
||||
Intent(this, ComposeActivity::class.java).apply {
|
||||
action = intent.action
|
||||
type = intent.type
|
||||
putExtras(intent)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
}
|
||||
startActivity(composeIntent)
|
||||
finish()
|
||||
}
|
||||
|
|
@ -438,13 +554,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
private fun setupDrawer(
|
||||
savedInstanceState: Bundle?,
|
||||
addSearchButton: Boolean,
|
||||
addTrendingButton: Boolean
|
||||
addTrendingTagsButton: Boolean,
|
||||
addTrendingStatusesButton: Boolean
|
||||
) {
|
||||
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
||||
|
||||
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
binding.topNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
binding.topNav.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
|
||||
header = AccountHeaderView(this).apply {
|
||||
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
|
||||
|
|
@ -468,17 +585,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
header.currentProfileName.ellipsize = TextUtils.TruncateAt.END
|
||||
|
||||
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
|
||||
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
header.accountHeaderBackground.setBackgroundColor(
|
||||
MaterialColors.getColor(header, R.attr.colorBackgroundAccent)
|
||||
)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
|
||||
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
|
||||
if (animateAvatars) {
|
||||
glide.load(uri)
|
||||
Glide.with(imageView)
|
||||
.load(uri)
|
||||
.placeholder(placeholder)
|
||||
.into(imageView)
|
||||
} else {
|
||||
glide.asBitmap()
|
||||
Glide.with(imageView)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(placeholder)
|
||||
.into(imageView)
|
||||
|
|
@ -486,12 +607,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
override fun cancel(imageView: ImageView) {
|
||||
glide.clear(imageView)
|
||||
// nothing to do, Glide already handles cancellation automatically
|
||||
}
|
||||
|
||||
override fun placeholder(ctx: Context, tag: String?): Drawable {
|
||||
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
|
||||
return ctx.getDrawable(R.drawable.avatar_default)!!
|
||||
return AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!!
|
||||
}
|
||||
|
||||
return super.placeholder(ctx, tag)
|
||||
|
|
@ -499,12 +620,33 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
})
|
||||
|
||||
binding.mainDrawer.apply {
|
||||
refreshMainDrawerItems(addSearchButton, addTrendingButton)
|
||||
refreshMainDrawerItems(
|
||||
addSearchButton = addSearchButton,
|
||||
addTrendingTagsButton = addTrendingTagsButton,
|
||||
addTrendingStatusesButton = addTrendingStatusesButton
|
||||
)
|
||||
setSavedInstance(savedInstanceState)
|
||||
}
|
||||
binding.mainDrawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener {
|
||||
override fun onDrawerSlide(drawerView: View, slideOffset: Float) { }
|
||||
|
||||
override fun onDrawerOpened(drawerView: View) {
|
||||
onBackPressedCallback.isEnabled = true
|
||||
}
|
||||
|
||||
override fun onDrawerClosed(drawerView: View) {
|
||||
onBackPressedCallback.isEnabled = binding.tabLayout.selectedTabPosition > 0
|
||||
}
|
||||
|
||||
override fun onDrawerStateChanged(newState: Int) { }
|
||||
})
|
||||
}
|
||||
|
||||
private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
|
||||
private fun refreshMainDrawerItems(
|
||||
addSearchButton: Boolean,
|
||||
addTrendingTagsButton: Boolean,
|
||||
addTrendingStatusesButton: Boolean
|
||||
) {
|
||||
binding.mainDrawer.apply {
|
||||
itemAdapter.clear()
|
||||
tintStatusBar = true
|
||||
|
|
@ -538,7 +680,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
nameRes = R.string.action_view_follow_requests
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
|
||||
onClick = {
|
||||
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
|
||||
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS)
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
},
|
||||
|
|
@ -621,7 +763,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
)
|
||||
}
|
||||
|
||||
if (addTrendingButton) {
|
||||
if (addTrendingTagsButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
5,
|
||||
primaryDrawerItem {
|
||||
|
|
@ -633,6 +775,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (addTrendingStatusesButton) {
|
||||
binding.mainDrawer.addItemsAtPosition(
|
||||
6,
|
||||
primaryDrawerItem {
|
||||
nameRes = R.string.title_public_trending_statuses
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department
|
||||
onClick = {
|
||||
startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
|
@ -702,6 +857,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
// Detach any existing mediator before changing tab contents and attaching a new mediator
|
||||
tabLayoutMediator?.detach()
|
||||
|
||||
directMessageTab = null
|
||||
|
||||
tabAdapter.tabs = tabs
|
||||
tabAdapter.notifyItemRangeChanged(0, tabs.size)
|
||||
|
||||
|
|
@ -712,6 +869,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
LIST -> tabs[position].arguments[1]
|
||||
else -> getString(tabs[position].text)
|
||||
}
|
||||
if (tabs[position].id == DIRECT) {
|
||||
val badge = tab.orCreateBadge
|
||||
badge.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false
|
||||
badge.backgroundColor = MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary)
|
||||
directMessageTab = tab
|
||||
}
|
||||
}.also { it.attach() }
|
||||
|
||||
// Selected tab is either
|
||||
|
|
@ -737,9 +900,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
onTabSelectedListener = object : OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
onBackPressedCallback.isEnabled = tab.position > 0 || binding.mainDrawerLayout.isOpen
|
||||
|
||||
binding.mainToolbar.title = tab.contentDescription
|
||||
|
||||
refreshComposeButtonState(tabAdapter, tab.position)
|
||||
|
||||
if (tab == directMessageTab) {
|
||||
tab.badge?.isVisible = false
|
||||
|
||||
accountManager.activeAccount?.let {
|
||||
if (it.hasDirectMessageBadge) {
|
||||
it.hasDirectMessageBadge = false
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||
|
|
@ -756,10 +932,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
activeTabLayout.addOnTabSelectedListener(it)
|
||||
}
|
||||
|
||||
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
|
||||
supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity)
|
||||
supportActionBar?.title = tabs[position].title(this@MainActivity)
|
||||
binding.mainToolbar.setOnClickListener {
|
||||
(tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
|
||||
(
|
||||
tabAdapter.getFragment(
|
||||
activeTabLayout.selectedTabPosition
|
||||
) as? ReselectableFragment
|
||||
)?.onReselect()
|
||||
}
|
||||
|
||||
updateProfiles()
|
||||
|
|
@ -790,7 +969,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
// open LoginActivity to add new account
|
||||
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
|
||||
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN))
|
||||
startActivityWithSlideInAnimation(
|
||||
LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN)
|
||||
)
|
||||
return false
|
||||
}
|
||||
// change Account
|
||||
|
|
@ -802,15 +983,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
cacheUpdater.stop()
|
||||
accountManager.setActiveAccount(newSelectedId)
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true)
|
||||
if (forward != null) {
|
||||
intent.type = forward.type
|
||||
intent.action = forward.action
|
||||
intent.putExtras(forward)
|
||||
}
|
||||
startActivity(intent)
|
||||
finishWithoutSlideOutAnimation()
|
||||
overridePendingTransition(R.anim.explode, R.anim.explode)
|
||||
finish()
|
||||
if (!supportsOverridingActivityTransitions()) {
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(R.anim.explode, R.anim.activity_open_exit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logout() {
|
||||
|
|
@ -833,7 +1017,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
|
||||
}
|
||||
startActivity(intent)
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
|
|
@ -853,17 +1037,26 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun onFetchUserInfoSuccess(me: Account) {
|
||||
glide.asBitmap()
|
||||
Glide.with(header.accountHeaderBackground)
|
||||
.asBitmap()
|
||||
.load(me.header)
|
||||
.into(header.accountHeaderBackground)
|
||||
|
||||
loadDrawerAvatar(me.avatar, false)
|
||||
|
||||
accountManager.updateActiveAccount(me)
|
||||
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
|
||||
NotificationHelper.createNotificationChannelsForAccount(
|
||||
accountManager.activeAccount!!,
|
||||
this
|
||||
)
|
||||
|
||||
// Setup push notifications
|
||||
showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
|
||||
showMigrationNoticeIfNecessary(
|
||||
this,
|
||||
binding.mainCoordinatorLayout,
|
||||
binding.composeButton,
|
||||
accountManager
|
||||
)
|
||||
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
|
||||
lifecycleScope.launch {
|
||||
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
|
||||
|
|
@ -872,122 +1065,94 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
disableAllNotifications(this, accountManager)
|
||||
}
|
||||
|
||||
accountLocked = me.locked
|
||||
|
||||
updateProfiles()
|
||||
updateShortcut(this, accountManager.activeAccount!!)
|
||||
shareShortcutHelper.updateShortcuts()
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
|
||||
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
if (hideTopToolbar) {
|
||||
val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom"
|
||||
|
||||
val avatarView = if (navOnBottom) {
|
||||
binding.bottomNavAvatar.show()
|
||||
binding.bottomNavAvatar
|
||||
val activeToolbar = if (hideTopToolbar) {
|
||||
val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom"
|
||||
if (navOnBottom) {
|
||||
binding.bottomNav
|
||||
} else {
|
||||
binding.topNavAvatar.show()
|
||||
binding.topNavAvatar
|
||||
}
|
||||
|
||||
if (animateAvatars) {
|
||||
Glide.with(this)
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
} else {
|
||||
Glide.with(this)
|
||||
.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
binding.topNav
|
||||
}
|
||||
} else {
|
||||
binding.bottomNavAvatar.hide()
|
||||
binding.topNavAvatar.hide()
|
||||
binding.mainToolbar
|
||||
}
|
||||
|
||||
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
|
||||
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
|
||||
|
||||
if (animateAvatars) {
|
||||
glide.asDrawable()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) {
|
||||
placeholder(R.drawable.avatar_default)
|
||||
if (animateAvatars) {
|
||||
Glide.with(this)
|
||||
.asDrawable()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
transition: Transition<in Drawable>?
|
||||
) {
|
||||
if (resource is Animatable) resource.start()
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
transition: Transition<in Drawable>?
|
||||
) {
|
||||
if (resource is Animatable) {
|
||||
resource.start()
|
||||
}
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(resource, navIconSize, navIconSize)
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
glide.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) {
|
||||
placeholder(R.drawable.avatar_default)
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Glide.with(this)
|
||||
.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.transform(
|
||||
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
|
||||
)
|
||||
.apply {
|
||||
if (showPlaceholder) placeholder(R.drawable.avatar_default)
|
||||
}
|
||||
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Bitmap,
|
||||
transition: Transition<in Bitmap>?
|
||||
) {
|
||||
binding.mainToolbar.navigationIcon = FixedSizeDrawable(
|
||||
BitmapDrawable(resources, resource),
|
||||
navIconSize,
|
||||
navIconSize
|
||||
)
|
||||
}
|
||||
override fun onResourceReady(
|
||||
resource: Bitmap,
|
||||
transition: Transition<in Bitmap>?
|
||||
) {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(
|
||||
BitmapDrawable(resources, resource),
|
||||
navIconSize,
|
||||
navIconSize
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
placeholder?.let {
|
||||
activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1007,7 +1172,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun updateAnnouncementsBadge() {
|
||||
binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()))
|
||||
binding.mainDrawer.updateBadge(
|
||||
DRAWER_ITEM_ANNOUNCEMENTS,
|
||||
StringHolder(
|
||||
if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateProfiles() {
|
||||
|
|
@ -1041,16 +1211,93 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
private fun explodeAnimationWasRequested(): Boolean {
|
||||
return intent.getBooleanExtra(OPEN_WITH_EXPLODE_ANIMATION, false)
|
||||
}
|
||||
|
||||
override fun getActionButton() = binding.composeButton
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
||||
companion object {
|
||||
const val OPEN_WITH_EXPLODE_ANIMATION = "explode"
|
||||
|
||||
private const val TAG = "MainActivity" // logging tag
|
||||
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
|
||||
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
|
||||
const val REDIRECT_URL = "redirectUrl"
|
||||
const val OPEN_DRAFTS = "draft"
|
||||
private const val REDIRECT_URL = "redirectUrl"
|
||||
private const val OPEN_DRAFTS = "draft"
|
||||
private const val TUSKY_ACCOUNT_ID = "tuskyAccountId"
|
||||
private const val COMPOSE_OPTIONS = "composeOptions"
|
||||
private const val NOTIFICATION_TYPE = "notificationType"
|
||||
private const val NOTIFICATION_TAG = "notificationTag"
|
||||
private const val NOTIFICATION_ID = "notificationId"
|
||||
|
||||
/**
|
||||
* Switches the active account to the provided accountId and then stays on MainActivity
|
||||
*/
|
||||
@JvmStatic
|
||||
fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openNotificationIntent(
|
||||
context: Context,
|
||||
tuskyAccountId: Long,
|
||||
type: Notification.Type
|
||||
): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(NOTIFICATION_TYPE, type.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active account to the accountId and then opens ComposeActivity with the provided options
|
||||
* @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account.
|
||||
* @param notificationId optional id of the notification that should be cancelled when this intent is opened
|
||||
* @param notificationTag optional tag of the notification that should be cancelled when this intent is opened
|
||||
*/
|
||||
@JvmStatic
|
||||
fun composeIntent(
|
||||
context: Context,
|
||||
options: ComposeActivity.ComposeOptions,
|
||||
tuskyAccountId: Long = -1,
|
||||
notificationTag: String? = null,
|
||||
notificationId: Int = -1
|
||||
): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
action = Intent.ACTION_SEND // so it can be opened via shortcuts
|
||||
putExtra(COMPOSE_OPTIONS, options)
|
||||
putExtra(NOTIFICATION_TAG, notificationTag)
|
||||
putExtra(NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* switches the active account to the accountId and then tries to resolve and show the provided url
|
||||
*/
|
||||
@JvmStatic
|
||||
fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(REDIRECT_URL, url)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* switches the active account to the provided accountId and then opens drafts
|
||||
*/
|
||||
fun draftIntent(context: Context, tuskyAccountId: Long): Intent {
|
||||
return accountSwitchIntent(context, tuskyAccountId).apply {
|
||||
putExtra(OPEN_DRAFTS, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,17 +27,20 @@ import at.connyduck.calladapter.networkresult.fold
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.filters.EditFilterActivity
|
||||
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
|
||||
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
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 kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
||||
|
|
@ -47,7 +50,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate)
|
||||
private val binding: ActivityStatuslistBinding by viewBinding(
|
||||
ActivityStatuslistBinding::inflate
|
||||
)
|
||||
private lateinit var kind: Kind
|
||||
private var hashtag: String? = null
|
||||
private var followTagItem: MenuItem? = null
|
||||
|
|
@ -74,6 +79,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
Kind.FAVOURITES -> getString(R.string.title_favourites)
|
||||
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
|
||||
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
|
||||
Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
|
||||
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
|
||||
}
|
||||
|
||||
|
|
@ -132,9 +138,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
{
|
||||
followTagItem?.isVisible = false
|
||||
unfollowTagItem?.isVisible = true
|
||||
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.following_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_following_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to follow #$tag", it)
|
||||
}
|
||||
)
|
||||
|
|
@ -152,9 +168,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
{
|
||||
followTagItem?.isVisible = true
|
||||
unfollowTagItem?.isVisible = false
|
||||
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.unfollowing_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_unfollowing_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to unfollow #$tag", it)
|
||||
}
|
||||
)
|
||||
|
|
@ -169,6 +195,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
*/
|
||||
private fun updateMuteTagMenuItems() {
|
||||
val tag = hashtag ?: return
|
||||
val hashedTag = "#$tag"
|
||||
|
||||
muteTagItem?.isVisible = true
|
||||
muteTagItem?.isEnabled = false
|
||||
|
|
@ -178,18 +205,17 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
mastodonApi.getFilters().fold(
|
||||
{ filters ->
|
||||
mutedFilter = filters.firstOrNull { filter ->
|
||||
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
|
||||
it.keyword == tag
|
||||
}
|
||||
// TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)?
|
||||
filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag
|
||||
}
|
||||
updateTagMuteState(mutedFilter != null)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
mastodonApi.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
mutedFilterV1 = filters.firstOrNull { filter ->
|
||||
tag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
|
||||
}
|
||||
updateTagMuteState(mutedFilterV1 != null)
|
||||
},
|
||||
|
|
@ -221,6 +247,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
val tag = hashtag ?: return true
|
||||
|
||||
lifecycleScope.launch {
|
||||
var filterCreateSuccess = false
|
||||
val hashedTag = "#$tag"
|
||||
|
||||
mastodonApi.createFilter(
|
||||
title = "#$tag",
|
||||
context = listOf(FilterV1.HOME),
|
||||
|
|
@ -228,19 +257,31 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
expiresInSeconds = null
|
||||
).fold(
|
||||
{ filter ->
|
||||
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
|
||||
mutedFilter = filter
|
||||
updateTagMuteState(true)
|
||||
if (mastodonApi.addFilterKeyword(
|
||||
filterId = filter.id,
|
||||
keyword = hashedTag,
|
||||
wholeWord = true
|
||||
).isSuccess
|
||||
) {
|
||||
// must be requested again; otherwise does not contain the keyword (but server does)
|
||||
mutedFilter = mastodonApi.getFilter(filter.id).getOrNull()
|
||||
|
||||
// TODO the preference key here ("home") is not meaningful; should probably be another event if any
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
filterCreateSuccess = true
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_muting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to mute #$tag")
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
mastodonApi.createFilterV1(
|
||||
tag,
|
||||
hashedTag,
|
||||
listOf(FilterV1.HOME),
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
|
@ -248,20 +289,50 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
).fold(
|
||||
{ filter ->
|
||||
mutedFilterV1 = filter
|
||||
updateTagMuteState(true)
|
||||
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
|
||||
filterCreateSuccess = true
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_muting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to mute #$tag", throwable)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_muting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to mute #$tag", throwable)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (filterCreateSuccess) {
|
||||
updateTagMuteState(true)
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.muting_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_LONG
|
||||
).apply {
|
||||
setAction(R.string.action_view_filter) {
|
||||
val intent = if (mutedFilter != null) {
|
||||
Intent(this@StatusListActivity, EditFilterActivity::class.java).apply {
|
||||
putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter)
|
||||
}
|
||||
} else {
|
||||
Intent(this@StatusListActivity, FiltersActivity::class.java)
|
||||
}
|
||||
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -307,9 +378,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
|
||||
mutedFilterV1 = null
|
||||
mutedFilter = null
|
||||
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.unmuting_hashtag_success_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.error_unmuting_hashtag_format, tag),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
Log.e(TAG, "Failed to unmute #$tag", throwable)
|
||||
}
|
||||
)
|
||||
|
|
@ -351,5 +432,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
putExtra(EXTRA_KIND, Kind.TAG.name)
|
||||
putExtra(EXTRA_HASHTAG, hashtag)
|
||||
}
|
||||
|
||||
fun newTrendingIntent(context: Context) =
|
||||
Intent(context, StatusListActivity::class.java).apply {
|
||||
putExtra(EXTRA_KIND, Kind.PUBLIC_TRENDING_STATUSES.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.trending.TrendingFragment
|
||||
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
|
||||
import com.keylesspalace.tusky.fragment.NotificationsFragment
|
||||
import java.util.Objects
|
||||
|
||||
/** this would be a good case for a sealed class, but that does not work nice with Room */
|
||||
|
|
@ -33,9 +33,11 @@ const val NOTIFICATIONS = "Notifications"
|
|||
const val LOCAL = "Local"
|
||||
const val FEDERATED = "Federated"
|
||||
const val DIRECT = "Direct"
|
||||
const val TRENDING = "Trending"
|
||||
const val TRENDING_TAGS = "TrendingTags"
|
||||
const val TRENDING_STATUSES = "TrendingStatuses"
|
||||
const val HASHTAG = "Hashtag"
|
||||
const val LIST = "List"
|
||||
const val BOOKMARKS = "Bookmarks"
|
||||
|
||||
data class TabData(
|
||||
val id: String,
|
||||
|
|
@ -52,9 +54,7 @@ data class TabData(
|
|||
other as TabData
|
||||
|
||||
if (id != other.id) return false
|
||||
if (arguments != other.arguments) return false
|
||||
|
||||
return true
|
||||
return arguments == other.arguments
|
||||
}
|
||||
|
||||
override fun hashCode() = Objects.hash(id, arguments)
|
||||
|
|
@ -94,11 +94,21 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
|||
icon = R.drawable.ic_reblog_direct_24dp,
|
||||
fragment = { ConversationsFragment.newInstance() }
|
||||
)
|
||||
TRENDING -> TabData(
|
||||
id = TRENDING,
|
||||
TRENDING_TAGS -> TabData(
|
||||
id = TRENDING_TAGS,
|
||||
text = R.string.title_public_trending_hashtags,
|
||||
icon = R.drawable.ic_trending_up_24px,
|
||||
fragment = { TrendingFragment.newInstance() }
|
||||
fragment = { TrendingTagsFragment.newInstance() }
|
||||
)
|
||||
TRENDING_STATUSES -> TabData(
|
||||
id = TRENDING_STATUSES,
|
||||
text = R.string.title_public_trending_statuses,
|
||||
icon = R.drawable.ic_hot_24dp,
|
||||
fragment = {
|
||||
TimelineFragment.newInstance(
|
||||
TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES
|
||||
)
|
||||
}
|
||||
)
|
||||
HASHTAG -> TabData(
|
||||
id = HASHTAG,
|
||||
|
|
@ -106,16 +116,31 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
|
|||
icon = R.drawable.ic_hashtag,
|
||||
fragment = { args -> TimelineFragment.newHashtagInstance(args) },
|
||||
arguments = arguments,
|
||||
title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
|
||||
title = { context ->
|
||||
arguments.joinToString(separator = " ") {
|
||||
context.getString(R.string.title_tag, it)
|
||||
}
|
||||
}
|
||||
)
|
||||
LIST -> TabData(
|
||||
id = LIST,
|
||||
text = R.string.list,
|
||||
icon = R.drawable.ic_list,
|
||||
fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) },
|
||||
fragment = { args ->
|
||||
TimelineFragment.newInstance(
|
||||
TimelineViewModel.Kind.LIST,
|
||||
args.getOrNull(0).orEmpty()
|
||||
)
|
||||
},
|
||||
arguments = arguments,
|
||||
title = { arguments.getOrNull(1).orEmpty() }
|
||||
)
|
||||
BOOKMARKS -> TabData(
|
||||
id = BOOKMARKS,
|
||||
text = R.string.title_bookmarks,
|
||||
icon = R.drawable.ic_bookmark_active_24dp,
|
||||
fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) }
|
||||
)
|
||||
else -> throw IllegalArgumentException("unknown tab type")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,18 +15,10 @@
|
|||
|
||||
package com.keylesspalace.tusky
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
|
|
@ -38,34 +30,29 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.TransitionManager
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import com.keylesspalace.tusky.adapter.ItemInteractionListener
|
||||
import com.keylesspalace.tusky.adapter.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.getDimension
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener {
|
||||
class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, ItemInteractionListener, ListSelectionFragment.ListSelectionListener {
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
|
@ -73,6 +60,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
private val binding by viewBinding(ActivityTabPreferenceBinding::inflate)
|
||||
|
||||
private lateinit var currentTabs: MutableList<TabData>
|
||||
|
|
@ -82,9 +72,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
private var tabsChanged = false
|
||||
|
||||
private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
|
||||
private val selectedItemElevation by unsafeLazy {
|
||||
resources.getDimension(R.dimen.selected_drag_item_elevation)
|
||||
}
|
||||
|
||||
private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
|
||||
private val hashtagRegex by unsafeLazy {
|
||||
Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE)
|
||||
}
|
||||
|
||||
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
|
|
@ -109,14 +103,19 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
|
||||
binding.currentTabsRecyclerView.adapter = currentTabsAdapter
|
||||
binding.currentTabsRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
||||
binding.currentTabsRecyclerView.addItemDecoration(
|
||||
DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
|
||||
)
|
||||
|
||||
addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this)
|
||||
binding.addTabRecyclerView.adapter = addTabAdapter
|
||||
binding.addTabRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
|
||||
touchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
|
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END)
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +127,11 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
return MIN_TAB_COUNT < currentTabs.size
|
||||
}
|
||||
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val temp = currentTabs[viewHolder.bindingAdapterPosition]
|
||||
currentTabs[viewHolder.bindingAdapterPosition] = currentTabs[target.bindingAdapterPosition]
|
||||
currentTabs[target.bindingAdapterPosition] = temp
|
||||
|
|
@ -148,7 +151,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
override fun clearView(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
viewHolder.itemView.elevation = 0f
|
||||
}
|
||||
|
|
@ -164,18 +170,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
toggleFab(false)
|
||||
}
|
||||
|
||||
binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT)
|
||||
|
||||
updateAvailableTabs()
|
||||
|
||||
onBackPressedDispatcher.addCallback(onFabDismissedCallback)
|
||||
}
|
||||
|
||||
override fun onTabAdded(tab: TabData) {
|
||||
if (currentTabs.size >= MAX_TAB_COUNT) {
|
||||
return
|
||||
}
|
||||
|
||||
toggleFab(false)
|
||||
|
||||
if (tab.id == HASHTAG) {
|
||||
|
|
@ -273,81 +273,24 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
editText.requestFocus()
|
||||
}
|
||||
|
||||
private var listSelectDialog: ListSelectionFragment? = null
|
||||
|
||||
private fun showSelectListDialog() {
|
||||
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
getItem(position)?.let { item -> (view as TextView).text = item.title }
|
||||
return view
|
||||
}
|
||||
}
|
||||
listSelectDialog = ListSelectionFragment.newInstance(null)
|
||||
listSelectDialog?.show(supportFragmentManager, null)
|
||||
|
||||
val statusLayout = LinearLayout(this)
|
||||
statusLayout.gravity = Gravity.CENTER
|
||||
val progress = ProgressBar(this)
|
||||
val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding)
|
||||
progress.setPadding(preferredPadding, 0, preferredPadding, 0)
|
||||
progress.visible(false)
|
||||
|
||||
val noListsText = TextView(this)
|
||||
noListsText.setPadding(preferredPadding, 0, preferredPadding, 0)
|
||||
noListsText.text = getText(R.string.select_list_empty)
|
||||
noListsText.visible(false)
|
||||
|
||||
statusLayout.addView(progress)
|
||||
statusLayout.addView(noListsText)
|
||||
|
||||
val dialogBuilder = AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_list_title)
|
||||
.setNeutralButton(R.string.select_list_manage) { _, _ ->
|
||||
val listIntent = Intent(applicationContext, ListsActivity::class.java)
|
||||
startActivity(listIntent)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setView(statusLayout)
|
||||
.setAdapter(adapter) { _, position ->
|
||||
adapter.getItem(position)?.let { item ->
|
||||
val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
}
|
||||
|
||||
val showProgressBarJob = getProgressBarJob(progress, 500)
|
||||
showProgressBarJob.start()
|
||||
|
||||
val dialog = dialogBuilder.show()
|
||||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.getLists().fold(
|
||||
{ lists ->
|
||||
showProgressBarJob.cancel()
|
||||
adapter.addAll(lists)
|
||||
if (lists.isEmpty()) {
|
||||
noListsText.show()
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
dialog.hide()
|
||||
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
|
||||
Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
|
||||
start = CoroutineStart.LAZY
|
||||
) {
|
||||
try {
|
||||
delay(delayMs)
|
||||
progressView.show()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
progressView.hide()
|
||||
}
|
||||
override fun onListSelected(list: MastoList) {
|
||||
listSelectDialog?.dismiss()
|
||||
listSelectDialog = null
|
||||
|
||||
val newTab = createTabDataFromId(LIST, listOf(list.id, list.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
|
||||
private fun validateHashtag(input: CharSequence?): Boolean {
|
||||
|
|
@ -378,17 +321,23 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
if (!currentTabs.contains(directMessagesTab)) {
|
||||
addableTabs.add(directMessagesTab)
|
||||
}
|
||||
val trendingTab = createTabDataFromId(TRENDING)
|
||||
if (!currentTabs.contains(trendingTab)) {
|
||||
addableTabs.add(trendingTab)
|
||||
val trendingTagsTab = createTabDataFromId(TRENDING_TAGS)
|
||||
if (!currentTabs.contains(trendingTagsTab)) {
|
||||
addableTabs.add(trendingTagsTab)
|
||||
}
|
||||
val bookmarksTab = createTabDataFromId(BOOKMARKS)
|
||||
if (!currentTabs.contains(bookmarksTab)) {
|
||||
addableTabs.add(bookmarksTab)
|
||||
}
|
||||
val trendingStatusesTab = createTabDataFromId(TRENDING_STATUSES)
|
||||
if (!currentTabs.contains(trendingStatusesTab)) {
|
||||
addableTabs.add(trendingStatusesTab)
|
||||
}
|
||||
|
||||
addableTabs.add(createTabDataFromId(HASHTAG))
|
||||
addableTabs.add(createTabDataFromId(LIST))
|
||||
|
||||
addTabAdapter.updateData(addableTabs)
|
||||
|
||||
binding.maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT)
|
||||
currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT)
|
||||
}
|
||||
|
||||
|
|
@ -419,8 +368,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
private const val MIN_TAB_COUNT = 2
|
||||
private const val MAX_TAB_COUNT = 5
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ import androidx.work.Constraints
|
|||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import autodispose2.AutoDisposePlugins
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.di.AppInjector
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
|
||||
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.setAppNightMode
|
||||
import com.keylesspalace.tusky.worker.PruneCacheWorker
|
||||
|
|
@ -37,11 +38,10 @@ import dagger.android.HasAndroidInjector
|
|||
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
|
||||
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
|
||||
import de.c1710.filemojicompat_ui.helpers.EmojiPreference
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.security.Security
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import org.conscrypt.Conscrypt
|
||||
|
||||
class TuskyApplication : Application(), HasAndroidInjector {
|
||||
@Inject
|
||||
|
|
@ -71,12 +71,13 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
|
||||
AutoDisposePlugins.setHideProxies(false) // a small performance optimization
|
||||
|
||||
AppInjector.init(this)
|
||||
|
||||
// Migrate shared preference keys and defaults from version to version.
|
||||
val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0)
|
||||
val oldVersion = sharedPreferences.getInt(
|
||||
PrefKeys.SCHEMA_VERSION,
|
||||
NEW_INSTALL_SCHEMA_VERSION
|
||||
)
|
||||
if (oldVersion != SCHEMA_VERSION) {
|
||||
upgradeSharedPreferences(oldVersion, SCHEMA_VERSION)
|
||||
}
|
||||
|
|
@ -87,15 +88,11 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
||||
|
||||
// init night mode
|
||||
val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value)
|
||||
setAppNightMode(theme)
|
||||
|
||||
localeManager.setLocale()
|
||||
|
||||
RxJavaPlugins.setErrorHandler {
|
||||
Log.w("RxJava", "undeliverable exception", it)
|
||||
}
|
||||
|
||||
NotificationHelper.createWorkerNotificationChannel(this)
|
||||
|
||||
WorkManager.initialize(
|
||||
|
|
@ -130,6 +127,27 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED)
|
||||
}
|
||||
|
||||
if (oldVersion < 2023072401) {
|
||||
// The notifications filter / clear options are shown on a menu, not a separate bar,
|
||||
// the preference to display them is not needed.
|
||||
editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER)
|
||||
}
|
||||
|
||||
if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) {
|
||||
// Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and
|
||||
// didn't have an explicit preference set use the previous default, so the
|
||||
// theme does not unexpectedly change.
|
||||
if (!sharedPreferences.contains(APP_THEME)) {
|
||||
editor.putString(APP_THEME, AppTheme.NIGHT.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < 2023112001) {
|
||||
editor.remove(PrefKeys.TAB_FILTER_HOME_REPLIES)
|
||||
editor.remove(PrefKeys.TAB_FILTER_HOME_BOOSTS)
|
||||
editor.remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS)
|
||||
}
|
||||
|
||||
editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion)
|
||||
editor.apply()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,19 +35,17 @@ import android.util.Log
|
|||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
|
||||
import autodispose2.autoDispose
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.FutureTarget
|
||||
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
|
||||
|
|
@ -57,20 +55,30 @@ import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
|||
import com.keylesspalace.tusky.pager.ImagePagerAdapter
|
||||
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
|
||||
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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
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
|
||||
|
||||
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
|
||||
class ViewMediaActivity :
|
||||
BaseActivity(),
|
||||
HasAndroidInjector,
|
||||
ViewImageFragment.PhotoActionsListener,
|
||||
ViewVideoFragment.VideoActionsListener {
|
||||
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
|
||||
|
||||
|
|
@ -97,7 +105,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
supportPostponeEnterTransition()
|
||||
|
||||
// Gather the parameters.
|
||||
attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
|
||||
attachments = IntentCompat.getParcelableArrayListExtra(
|
||||
intent,
|
||||
EXTRA_ATTACHMENTS,
|
||||
AttachmentViewData::class.java
|
||||
)
|
||||
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
|
||||
|
||||
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
|
||||
|
|
@ -119,6 +131,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
binding.toolbar.title = getPageTitle(position)
|
||||
adjustScreenWakefulness()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -150,6 +163,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
window.sharedElementEnterTransition.removeListener(this)
|
||||
}
|
||||
})
|
||||
|
||||
adjustScreenWakefulness()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
|
|
@ -206,7 +221,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
private fun downloadMedia() {
|
||||
val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url
|
||||
val filename = Uri.parse(url).lastPathSegment
|
||||
Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
resources.getString(R.string.download_image, filename),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
|
|
@ -216,8 +235,13 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
|
||||
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) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
) { _, grantResults ->
|
||||
if (
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
downloadMedia()
|
||||
} else {
|
||||
showErrorDialog(
|
||||
|
|
@ -234,7 +258,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
|
||||
private fun onOpenStatus() {
|
||||
val attach = attachments!![binding.viewPager.currentItem]
|
||||
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl))
|
||||
startActivityWithSlideInAnimation(
|
||||
ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl)
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyLink() {
|
||||
|
|
@ -267,7 +293,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
private fun shareFile(file: File, mimeType: String?) {
|
||||
ShareCompat.IntentBuilder(this)
|
||||
.setType(mimeType)
|
||||
.addStream(FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
|
||||
.addStream(
|
||||
FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file)
|
||||
)
|
||||
.setChooserTitle(R.string.send_media_to)
|
||||
.startChooser()
|
||||
}
|
||||
|
|
@ -278,46 +306,37 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
isCreating = true
|
||||
binding.progressBarShare.visibility = View.VISIBLE
|
||||
invalidateOptionsMenu()
|
||||
val file = File(directory, getTemporaryMediaFilename("png"))
|
||||
val futureTask: FutureTarget<Bitmap> =
|
||||
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit()
|
||||
Single.fromCallable {
|
||||
val bitmap = futureTask.get()
|
||||
try {
|
||||
val stream = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.close()
|
||||
return@fromCallable true
|
||||
} catch (fnfe: FileNotFoundException) {
|
||||
Log.e(TAG, "Error writing temporary media.")
|
||||
} catch (ioe: IOException) {
|
||||
Log.e(TAG, "Error writing temporary media.")
|
||||
}
|
||||
return@fromCallable false
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnDispose {
|
||||
futureTask.cancel(true)
|
||||
}
|
||||
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{ result ->
|
||||
Log.d(TAG, "Download image result: $result")
|
||||
isCreating = false
|
||||
invalidateOptionsMenu()
|
||||
binding.progressBarShare.visibility = View.GONE
|
||||
if (result) {
|
||||
shareFile(file, "image/png")
|
||||
|
||||
lifecycleScope.launch {
|
||||
val file = File(directory, getTemporaryMediaFilename("png"))
|
||||
val result = try {
|
||||
val bitmap =
|
||||
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync()
|
||||
try {
|
||||
FileOutputStream(file).use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
isCreating = false
|
||||
invalidateOptionsMenu()
|
||||
binding.progressBarShare.visibility = View.GONE
|
||||
Log.e(TAG, "Failed to download image", error)
|
||||
true
|
||||
} catch (ioe: IOException) {
|
||||
// FileNotFoundException is covered by IOException
|
||||
Log.e(TAG, "Error writing temporary media.")
|
||||
false
|
||||
}.also { result -> Log.d(TAG, "Download image result: $result") }
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) {
|
||||
throw error
|
||||
}
|
||||
)
|
||||
Log.e(TAG, "Failed to download image", error)
|
||||
false
|
||||
}
|
||||
|
||||
isCreating = false
|
||||
invalidateOptionsMenu()
|
||||
binding.progressBarShare.visibility = View.GONE
|
||||
if (result) {
|
||||
shareFile(file, "image/png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareMediaFile(directory: File, url: String) {
|
||||
|
|
@ -337,6 +356,19 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
shareFile(file, mimeType)
|
||||
}
|
||||
|
||||
// Prevent this activity from dimming or sleeping the screen if, and only if, it is playing video or audio
|
||||
private fun adjustScreenWakefulness() {
|
||||
attachments?.run {
|
||||
if (get(binding.viewPager.currentItem).attachment.type == Attachment.Type.IMAGE) {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ATTACHMENTS = "attachments"
|
||||
private const val EXTRA_ATTACHMENT_INDEX = "index"
|
||||
|
|
@ -344,7 +376,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
private const val TAG = "ViewMediaActivity"
|
||||
|
||||
@JvmStatic
|
||||
fun newIntent(context: Context?, attachments: List<AttachmentViewData>, index: Int): Intent {
|
||||
fun newIntent(
|
||||
context: Context?,
|
||||
attachments: List<AttachmentViewData>,
|
||||
index: Int
|
||||
): Intent {
|
||||
val intent = Intent(context, ViewMediaActivity::class.java)
|
||||
intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, ArrayList(attachments))
|
||||
intent.putExtra(EXTRA_ATTACHMENT_INDEX, index)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ import com.keylesspalace.tusky.entity.StringField
|
|||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.fixTextSelection
|
||||
|
||||
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
|
||||
class AccountFieldEditAdapter(
|
||||
var onFieldsChanged: () -> Unit = { }
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
|
||||
|
||||
private val fieldData = mutableListOf<MutableStringPair>()
|
||||
private var maxNameLength: Int? = null
|
||||
|
|
@ -62,8 +64,15 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
|
||||
override fun getItemCount() = fieldData.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemEditFieldBinding> {
|
||||
val binding = ItemEditFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemEditFieldBinding> {
|
||||
val binding = ItemEditFieldBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
|
|
@ -83,10 +92,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
|
||||
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
|
||||
onFieldsChanged()
|
||||
}
|
||||
|
||||
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
|
||||
onFieldsChanged()
|
||||
}
|
||||
|
||||
// Ensure the textview contents are selectable
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
||||
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(context, R.layout.item_autocomplete_account) {
|
||||
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(
|
||||
context,
|
||||
R.layout.item_autocomplete_account
|
||||
) {
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val binding = if (convertView == null) {
|
||||
|
|
@ -47,7 +50,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
|
|||
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
|
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
|
||||
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,20 @@ class EmojiAdapter(
|
|||
private val animate: Boolean
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
|
||||
|
||||
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
|
||||
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker }
|
||||
.sortedBy { it.shortcode.lowercase(Locale.ROOT) }
|
||||
|
||||
override fun getItemCount() = emojiList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemEmojiButtonBinding> {
|
||||
val binding = ItemEmojiButtonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemEmojiButtonBinding> {
|
||||
val binding = ItemEmojiButtonBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,17 +16,15 @@
|
|||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
|
@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowRequestViewHolder(
|
||||
private val binding: ItemFollowRequestBinding,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
private val showHeader: Boolean
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithAccount(
|
||||
viewData.account,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis,
|
||||
statusDisplayOptions.showBotOverlay
|
||||
)
|
||||
|
||||
setupActionListener(accountActionListener, viewData.account.id)
|
||||
}
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setupWithAccount(
|
||||
account: TimelineAccount,
|
||||
|
|
@ -72,7 +49,7 @@ class FollowRequestViewHolder(
|
|||
val wrappedName = account.name.unicodeWrap()
|
||||
val emojifiedName: CharSequence = wrappedName.emojify(
|
||||
account.emojis,
|
||||
itemView,
|
||||
binding.displayNameTextView,
|
||||
animateEmojis
|
||||
)
|
||||
binding.displayNameTextView.text = emojifiedName
|
||||
|
|
@ -81,17 +58,15 @@ class FollowRequestViewHolder(
|
|||
R.string.notification_follow_request_format,
|
||||
wrappedName
|
||||
)
|
||||
binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply {
|
||||
setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
0,
|
||||
wrappedName.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}.emojify(account.emojis, itemView, animateEmojis)
|
||||
binding.notificationTextView.text = SpannableString(wholeMessage).apply {
|
||||
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}.emojify(account.emojis, binding.notificationTextView, animateEmojis)
|
||||
}
|
||||
binding.notificationTextView.visible(showHeader)
|
||||
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
|
||||
val formattedUsername = itemView.context.getString(
|
||||
R.string.post_username_format,
|
||||
account.username
|
||||
)
|
||||
binding.usernameTextView.text = formattedUsername
|
||||
if (account.note.isEmpty()) {
|
||||
binding.accountNote.hide()
|
||||
|
|
@ -102,7 +77,9 @@ class FollowRequestViewHolder(
|
|||
.emojify(account.emojis, binding.accountNote, animateEmojis)
|
||||
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
|
||||
}
|
||||
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_48dp
|
||||
)
|
||||
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
|
||||
binding.avatarBadge.visible(showBotOverlay && account.bot)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import com.keylesspalace.tusky.util.getTuskyDisplayName
|
|||
import com.keylesspalace.tusky.util.modernLanguageCode
|
||||
import java.util.Locale
|
||||
|
||||
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) {
|
||||
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(
|
||||
context,
|
||||
resource,
|
||||
locales
|
||||
) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return (super.getView(position, convertView, parent) as TextView).apply {
|
||||
setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,708 @@
|
|||
/* 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.tusky_blue);
|
||||
format = context.getString(R.string.notification_subscription_format);
|
||||
break;
|
||||
}
|
||||
case UPDATE: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_update_format);
|
||||
break;
|
||||
}
|
||||
}
|
||||
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
|
||||
String wholeMessage = String.format(format, displayName);
|
||||
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
|
||||
int displayNameIndex = format.indexOf("%s");
|
||||
str.setSpan(
|
||||
new StyleSpan(Typeface.BOLD),
|
||||
displayNameIndex,
|
||||
displayNameIndex + displayName.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
);
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
message.setText(emojifiedText);
|
||||
|
||||
if (statusViewData != null) {
|
||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
|
||||
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
|
||||
if (statusViewData.isExpanded()) {
|
||||
contentWarningButton.setText(R.string.post_content_warning_show_less);
|
||||
} else {
|
||||
contentWarningButton.setText(R.string.post_content_warning_show_more);
|
||||
}
|
||||
|
||||
contentWarningButton.setOnClickListener(view -> {
|
||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||
notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition());
|
||||
}
|
||||
statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE);
|
||||
});
|
||||
|
||||
setupContentAndSpoiler(listener);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void setupButtons(final NotificationActionListener listener, final String accountId,
|
||||
final String notificationId) {
|
||||
this.notificationActionListener = listener;
|
||||
this.accountId = accountId;
|
||||
this.notificationId = notificationId;
|
||||
}
|
||||
|
||||
void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) {
|
||||
statusAvatar.setPaddingRelative(0, 0, 0, 0);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||
statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
Glide.with(notificationAvatar)
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(notificationAvatar);
|
||||
|
||||
} else {
|
||||
notificationAvatar.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) {
|
||||
int padding = Utils.dpToPx(statusAvatar.getContext(), 12);
|
||||
statusAvatar.setPaddingRelative(0, 0, padding, padding);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||
statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
||||
avatarRadius24dp, statusDisplayOptions.animateAvatars(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (notificationActionListener == null)
|
||||
return;
|
||||
|
||||
if (v == container || v == statusContent) {
|
||||
notificationActionListener.onViewStatusForNotificationId(notificationId);
|
||||
}
|
||||
else if (v == message) {
|
||||
notificationActionListener.onViewAccount(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupContentAndSpoiler(final LinkListener listener) {
|
||||
|
||||
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
|
||||
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
|
||||
if (!shouldShowContentIfSpoiler && hasSpoiler) {
|
||||
statusContent.setVisibility(View.GONE);
|
||||
} else {
|
||||
statusContent.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
Spanned content = statusViewData.getContent();
|
||||
List<Emoji> emojis = statusViewData.getActionable().getEmojis();
|
||||
|
||||
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
|
||||
contentCollapseButton.setOnClickListener(view -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION && notificationActionListener != null) {
|
||||
notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position);
|
||||
}
|
||||
});
|
||||
|
||||
contentCollapseButton.setVisibility(View.VISIBLE);
|
||||
if (statusViewData.isCollapsed()) {
|
||||
contentCollapseButton.setText(R.string.post_content_warning_show_more);
|
||||
statusContent.setFilters(COLLAPSE_INPUT_FILTER);
|
||||
} else {
|
||||
contentCollapseButton.setText(R.string.post_content_warning_show_less);
|
||||
statusContent.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
} else {
|
||||
contentCollapseButton.setVisibility(View.GONE);
|
||||
statusContent.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(
|
||||
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
|
||||
|
||||
CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify(
|
||||
statusViewData.getStatus().getSpoilerText(),
|
||||
statusViewData.getActionable().getEmojis(),
|
||||
contentWarningDescriptionTextView,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
contentWarningDescriptionTextView.setText(emojifiedContentWarning);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onViewTag(@NonNull String tag) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewAccount(@NonNull String id) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewUrl(@NonNull String url) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +67,10 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
.map { pollOptions.indexOf(it) }
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemPollBinding> {
|
||||
val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ class PreviewPollOptionsAdapter : RecyclerView.Adapter<PreviewViewHolder>() {
|
|||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
|
||||
return PreviewViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_poll_preview_option, parent, false))
|
||||
return PreviewViewHolder(
|
||||
LayoutInflater.from(
|
||||
parent.context
|
||||
).inflate(R.layout.item_poll_preview_option, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount() = options.size
|
||||
|
|
|
|||
|
|
@ -20,76 +20,33 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationActionListener
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter
|
||||
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
|
||||
import com.keylesspalace.tusky.entity.Report
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import java.util.Date
|
||||
|
||||
class ReportNotificationViewHolder(
|
||||
private val binding: ItemReportNotificationBinding,
|
||||
private val notificationActionListener: NotificationActionListener
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
private val binding: ItemReportNotificationBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
override fun bind(
|
||||
viewData: NotificationViewData,
|
||||
payloads: List<*>?,
|
||||
statusDisplayOptions: StatusDisplayOptions
|
||||
) {
|
||||
// Skip updates with payloads. That indicates a timestamp update, and
|
||||
// this view does not have timestamps.
|
||||
if (!payloads.isNullOrEmpty()) return
|
||||
|
||||
setupWithReport(
|
||||
viewData.account,
|
||||
viewData.report!!,
|
||||
statusDisplayOptions.animateAvatars,
|
||||
statusDisplayOptions.animateEmojis
|
||||
)
|
||||
setupActionListener(
|
||||
notificationActionListener,
|
||||
viewData.report.targetAccount.id,
|
||||
viewData.account.id,
|
||||
viewData.report.id
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupWithReport(
|
||||
fun setupWithReport(
|
||||
reporter: TimelineAccount,
|
||||
report: Report,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(
|
||||
reporter.emojis,
|
||||
binding.root,
|
||||
animateEmojis
|
||||
)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(
|
||||
report.targetAccount.emojis,
|
||||
itemView,
|
||||
animateEmojis
|
||||
)
|
||||
val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp)
|
||||
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, binding.notificationTopText, animateEmojis)
|
||||
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, binding.notificationTopText, animateEmojis)
|
||||
|
||||
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
|
||||
|
||||
binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
|
||||
binding.notificationTopText.text = itemView.context.getString(
|
||||
R.string.notification_header_report_format,
|
||||
reporterName,
|
||||
reporteeName
|
||||
)
|
||||
binding.notificationSummary.text = itemView.context.getString(
|
||||
R.string.notification_summary_report_format,
|
||||
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
|
||||
report.status_ids?.size ?: 0
|
||||
)
|
||||
binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName)
|
||||
binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0)
|
||||
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
|
||||
|
||||
// Fancy avatar inset
|
||||
|
|
@ -110,7 +67,7 @@ class ReportNotificationViewHolder(
|
|||
)
|
||||
}
|
||||
|
||||
private fun setupActionListener(
|
||||
fun setupActionListener(
|
||||
listener: NotificationActionListener,
|
||||
reporteeId: String,
|
||||
reporterId: String,
|
||||
|
|
|
|||
|
|
@ -48,13 +48,16 @@ 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.Translation;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||
import com.keylesspalace.tusky.util.AttachmentHelper;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.LocaleUtilsKt;
|
||||
import com.keylesspalace.tusky.util.NumberUtils;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
|
|
@ -65,10 +68,13 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData;
|
|||
import com.keylesspalace.tusky.viewdata.PollViewData;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
import com.keylesspalace.tusky.viewdata.TranslationViewData;
|
||||
|
||||
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;
|
||||
|
|
@ -114,10 +120,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private final TextView cardDescription;
|
||||
private final TextView cardUrl;
|
||||
private final PollAdapter pollAdapter;
|
||||
protected LinearLayout filteredPlaceholder;
|
||||
protected TextView filteredPlaceholderLabel;
|
||||
protected Button filteredPlaceholderShowButton;
|
||||
protected ConstraintLayout statusContainer;
|
||||
protected final LinearLayout filteredPlaceholder;
|
||||
protected final TextView filteredPlaceholderLabel;
|
||||
protected final Button filteredPlaceholderShowButton;
|
||||
protected final ConstraintLayout statusContainer;
|
||||
private final TextView translationStatusView;
|
||||
private final Button untranslateButton;
|
||||
|
||||
|
||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
|
@ -128,7 +137,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
private final Drawable mediaPreviewUnloaded;
|
||||
|
||||
protected StatusBaseViewHolder(View itemView) {
|
||||
protected StatusBaseViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
|
|
@ -149,10 +158,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning);
|
||||
sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button);
|
||||
mediaLabels = new TextView[]{
|
||||
itemView.findViewById(R.id.status_media_label_0),
|
||||
itemView.findViewById(R.id.status_media_label_1),
|
||||
itemView.findViewById(R.id.status_media_label_2),
|
||||
itemView.findViewById(R.id.status_media_label_3)
|
||||
itemView.findViewById(R.id.status_media_label_0),
|
||||
itemView.findViewById(R.id.status_media_label_1),
|
||||
itemView.findViewById(R.id.status_media_label_2),
|
||||
itemView.findViewById(R.id.status_media_label_3)
|
||||
};
|
||||
mediaDescriptions = new CharSequence[mediaLabels.length];
|
||||
contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description);
|
||||
|
|
@ -180,6 +189,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
||||
((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false);
|
||||
|
||||
translationStatusView = itemView.findViewById(R.id.status_translation_status);
|
||||
untranslateButton = itemView.findViewById(R.id.status_button_untranslate);
|
||||
|
||||
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);
|
||||
|
|
@ -189,14 +201,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
|
||||
}
|
||||
|
||||
protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
|
||||
protected void setDisplayName(@NonNull String name, @Nullable List<Emoji> customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(
|
||||
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
|
||||
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
displayName.setText(emojifiedName);
|
||||
}
|
||||
|
||||
protected void setUsername(String name) {
|
||||
protected void setUsername(@Nullable String name) {
|
||||
Context context = username.getContext();
|
||||
String usernameText = context.getString(R.string.post_username_format, name);
|
||||
username.setText(usernameText);
|
||||
|
|
@ -208,7 +220,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener) {
|
||||
final @NonNull StatusActionListener listener) {
|
||||
|
||||
Status actionable = status.getActionable();
|
||||
String spoilerText = status.getSpoilerText();
|
||||
|
|
@ -219,7 +231,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
if (sensitive) {
|
||||
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
|
||||
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
|
||||
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
contentWarningDescription.setText(emojiSpoiler);
|
||||
contentWarningDescription.setVisibility(View.VISIBLE);
|
||||
|
|
@ -269,9 +281,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
Status actionable = status.getActionable();
|
||||
Spanned content = status.getContent();
|
||||
List<Status.Mention> mentions = actionable.getMentions();
|
||||
List<HashTag> tags =actionable.getTags();
|
||||
List<HashTag> tags = actionable.getTags();
|
||||
List<Emoji> emojis = actionable.getEmojis();
|
||||
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
|
||||
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
|
||||
|
||||
if (expanded) {
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
||||
|
|
@ -302,7 +314,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
private void setAvatar(String url,
|
||||
@Nullable String rebloggedUrl,
|
||||
@Nullable String rebloggedUrl,
|
||||
boolean isBot,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
|
||||
|
|
@ -313,8 +325,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
avatarInset.setVisibility(View.VISIBLE);
|
||||
Glide.with(avatarInset)
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(avatarInset);
|
||||
.load(R.drawable.bot_badge)
|
||||
.into(avatarInset);
|
||||
} else {
|
||||
avatarInset.setVisibility(View.GONE);
|
||||
}
|
||||
|
|
@ -328,17 +340,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
avatarInset.setVisibility(View.VISIBLE);
|
||||
avatarInset.setBackground(null);
|
||||
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
statusDisplayOptions.animateAvatars(), null);
|
||||
|
||||
avatarRadius = avatarRadius36dp;
|
||||
}
|
||||
|
||||
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
|
||||
ImageLoadingHelper.loadAvatar(
|
||||
url,
|
||||
avatar,
|
||||
avatarRadius,
|
||||
statusDisplayOptions.animateAvatars(),
|
||||
Collections.singletonList(new CompositeWithOpaqueBackground(MaterialColors.getColor(avatar, android.R.attr.colorBackground)))
|
||||
);
|
||||
}
|
||||
|
||||
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
Date createdAt = status.getCreatedAt();
|
||||
|
|
@ -377,8 +393,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
long then = createdAt.getTime();
|
||||
long now = System.currentTimeMillis();
|
||||
return DateUtils.getRelativeTimeSpanString(then, now,
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -461,9 +477,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
imageView.removeFocalPoint();
|
||||
|
||||
Glide.with(imageView)
|
||||
.load(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
.load(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
} else {
|
||||
Focus focus = meta != null ? meta.getFocus() : null;
|
||||
|
||||
|
|
@ -471,29 +487,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
imageView.setFocalPoint(focus);
|
||||
|
||||
Glide.with(imageView.getContext())
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.addListener(imageView)
|
||||
.into(imageView);
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.addListener(imageView)
|
||||
.into(imageView);
|
||||
} else {
|
||||
imageView.removeFocalPoint();
|
||||
|
||||
Glide.with(imageView)
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void setMediaPreviews(
|
||||
final List<Attachment> attachments,
|
||||
boolean sensitive,
|
||||
final StatusActionListener listener,
|
||||
boolean showingContent,
|
||||
boolean useBlurhash
|
||||
final @NonNull List<Attachment> attachments,
|
||||
boolean sensitive,
|
||||
final @NonNull StatusActionListener listener,
|
||||
boolean showingContent,
|
||||
boolean useBlurhash
|
||||
) {
|
||||
|
||||
mediaPreview.setVisibility(View.VISIBLE);
|
||||
|
|
@ -512,10 +528,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
loadImage(
|
||||
imageView,
|
||||
showingContent ? previewUrl : null,
|
||||
attachment.getMeta(),
|
||||
useBlurhash ? attachment.getBlurhash() : null
|
||||
imageView,
|
||||
showingContent ? previewUrl : null,
|
||||
attachment.getMeta(),
|
||||
useBlurhash ? attachment.getBlurhash() : null
|
||||
);
|
||||
|
||||
final Attachment.Type type = attachment.getType();
|
||||
|
|
@ -577,13 +593,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) {
|
||||
Context context = itemView.getContext();
|
||||
CharSequence label = (sensitive && !showingContent) ?
|
||||
context.getString(R.string.post_sensitive_media_title) :
|
||||
mediaDescriptions[index];
|
||||
context.getString(R.string.post_sensitive_media_title) :
|
||||
mediaDescriptions[index];
|
||||
mediaLabels[index].setText(label);
|
||||
}
|
||||
|
||||
protected void setMediaLabel(List<Attachment> attachments, boolean sensitive,
|
||||
final StatusActionListener listener, boolean showingContent) {
|
||||
protected void setMediaLabel(@NonNull List<Attachment> attachments, boolean sensitive,
|
||||
final @NonNull StatusActionListener listener, boolean showingContent) {
|
||||
Context context = itemView.getContext();
|
||||
for (int i = 0; i < mediaLabels.length; i++) {
|
||||
TextView mediaLabel = mediaLabels[i];
|
||||
|
|
@ -604,7 +620,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private void setAttachmentClickListener(View view, StatusActionListener listener,
|
||||
private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener,
|
||||
int index, Attachment attachment, boolean animateTransition) {
|
||||
view.setOnClickListener(v -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
|
|
@ -628,10 +644,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
sensitiveMediaShow.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
protected void setupButtons(final StatusActionListener listener,
|
||||
final String accountId,
|
||||
final String statusContent,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
protected void setupButtons(final @NonNull StatusActionListener listener,
|
||||
final @NonNull String accountId,
|
||||
final @Nullable String statusContent,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId);
|
||||
|
||||
avatar.setOnClickListener(profileButtonClickListener);
|
||||
|
|
@ -721,8 +737,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
popup.setOnMenuItemClickListener(item -> {
|
||||
listener.onReblog(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
if (!buttonState) {
|
||||
reblogButton.playAnimation();
|
||||
reblogButton.setChecked(true);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
|
@ -742,16 +759,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
popup.setOnMenuItemClickListener(item -> {
|
||||
listener.onFavourite(!buttonState, position);
|
||||
if(!buttonState) {
|
||||
if (!buttonState) {
|
||||
favouriteButton.playAnimation();
|
||||
favouriteButton.setChecked(true);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
popup.show();
|
||||
}
|
||||
|
||||
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
this.setupWithStatus(status, listener, statusDisplayOptions, null);
|
||||
}
|
||||
|
||||
|
|
@ -762,16 +780,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (payloads == null) {
|
||||
Status actionable = status.getActionable();
|
||||
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
|
||||
setUsername(status.getUsername());
|
||||
setUsername(actionable.getAccount().getUsername());
|
||||
setMetaData(status, statusDisplayOptions, listener);
|
||||
setIsReply(actionable.getInReplyToId() != null);
|
||||
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
|
||||
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||
setReblogged(actionable.getReblogged());
|
||||
setFavourited(actionable.getFavourited());
|
||||
setBookmarked(actionable.getBookmarked());
|
||||
List<Attachment> attachments = actionable.getAttachments();
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = actionable.getSensitive();
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
|
||||
|
|
@ -793,8 +811,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
|
||||
|
||||
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
|
||||
statusDisplayOptions);
|
||||
|
||||
setTranslationStatus(status, listener);
|
||||
|
||||
setRebloggingEnabled(actionable.isRebloggingAllowed(), actionable.getVisibility());
|
||||
|
||||
setSpoilerAndContent(status, statusDisplayOptions, listener);
|
||||
|
||||
|
|
@ -819,6 +840,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private void setTranslationStatus(StatusViewData.Concrete status, StatusActionListener listener) {
|
||||
var translationViewData = status.getTranslation();
|
||||
if (translationViewData != null) {
|
||||
if (translationViewData instanceof TranslationViewData.Loaded) {
|
||||
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
|
||||
translationStatusView.setVisibility(View.VISIBLE);
|
||||
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
|
||||
translationStatusView.setText(translationStatusView.getContext().getString(R.string.label_translated, langName, translation.getProvider()));
|
||||
untranslateButton.setVisibility(View.VISIBLE);
|
||||
untranslateButton.setOnClickListener((v) -> listener.onUntranslate(getBindingAdapterPosition()));
|
||||
} else {
|
||||
translationStatusView.setVisibility(View.VISIBLE);
|
||||
translationStatusView.setText(R.string.label_translating);
|
||||
untranslateButton.setVisibility(View.GONE);
|
||||
untranslateButton.setOnClickListener(null);
|
||||
}
|
||||
} else {
|
||||
translationStatusView.setVisibility(View.GONE);
|
||||
untranslateButton.setVisibility(View.GONE);
|
||||
untranslateButton.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) {
|
||||
if (status.getFilterAction() != Filter.Action.WARN) {
|
||||
showFilteredPlaceholder(false);
|
||||
|
|
@ -838,12 +882,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle()));
|
||||
filteredPlaceholderShowButton.setOnClickListener(view -> {
|
||||
listener.clearWarningAction(getBindingAdapterPosition());
|
||||
});
|
||||
filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition()));
|
||||
}
|
||||
|
||||
protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
|
||||
protected static boolean hasPreviewableAttachment(@NonNull List<Attachment> attachments) {
|
||||
for (Attachment attachment : attachments) {
|
||||
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
|
||||
return false;
|
||||
|
|
@ -858,54 +900,84 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
Status actionable = status.getActionable();
|
||||
|
||||
String description = context.getString(R.string.description_status,
|
||||
actionable.getAccount().getDisplayName(),
|
||||
getContentWarningDescription(context, status),
|
||||
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
||||
getReblogDescription(context, status),
|
||||
status.getUsername(),
|
||||
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
|
||||
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
|
||||
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
|
||||
getMediaDescription(context, status),
|
||||
getVisibilityDescription(context, actionable.getVisibility()),
|
||||
getFavsText(context, actionable.getFavouritesCount()),
|
||||
getReblogsText(context, actionable.getReblogsCount()),
|
||||
getPollDescription(status, context, statusDisplayOptions)
|
||||
// 1 display_name
|
||||
actionable.getAccount().getDisplayName(),
|
||||
// 2 CW?
|
||||
getContentWarningDescription(context, status),
|
||||
// 3 content?
|
||||
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
// 4 date
|
||||
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||
// 5 edited?
|
||||
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
||||
// 6 reposted_by?
|
||||
getReblogDescription(context, status),
|
||||
// 7 username
|
||||
actionable.getAccount().getUsername(),
|
||||
// 8 reposted
|
||||
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
|
||||
// 9 favorited
|
||||
actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
|
||||
// 10 bookmarked
|
||||
actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
|
||||
// 11 media
|
||||
getMediaDescription(context, status),
|
||||
// 12 visibility
|
||||
getVisibilityDescription(context, actionable.getVisibility()),
|
||||
// 13 fav_number
|
||||
getFavsText(context, actionable.getFavouritesCount()),
|
||||
// 14 reblog_number
|
||||
getReblogsText(context, actionable.getReblogsCount()),
|
||||
// 15 poll?
|
||||
getPollDescription(status, context, statusDisplayOptions),
|
||||
// 16 translated?
|
||||
getTranslatedDescription(context, status.getTranslation())
|
||||
);
|
||||
itemView.setContentDescription(description);
|
||||
}
|
||||
|
||||
private String getTranslatedDescription(Context context, TranslationViewData translationViewData) {
|
||||
if (translationViewData == null) {
|
||||
return "";
|
||||
} else if (translationViewData instanceof TranslationViewData.Loading) {
|
||||
return context.getString(R.string.label_translating);
|
||||
} else {
|
||||
Translation translation = ((TranslationViewData.Loaded) translationViewData).getData();
|
||||
var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage());
|
||||
return context.getString(R.string.label_translated, langName, translation.getProvider());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharSequence getReblogDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
@Nullable
|
||||
Status reblog = status.getRebloggingStatus();
|
||||
if (reblog != null) {
|
||||
return context
|
||||
.getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
|
||||
.getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static CharSequence getMediaDescription(Context context,
|
||||
@NonNull StatusViewData.Concrete status) {
|
||||
if (status.getActionable().getAttachments().isEmpty()) {
|
||||
@NonNull StatusViewData.Concrete viewData) {
|
||||
if (viewData.getAttachments().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder mediaDescriptions = CollectionsKt.fold(
|
||||
status.getActionable().getAttachments(),
|
||||
new StringBuilder(),
|
||||
(builder, a) -> {
|
||||
if (a.getDescription() == null) {
|
||||
String placeholder =
|
||||
context.getString(R.string.description_post_media_no_description_placeholder);
|
||||
return builder.append(placeholder);
|
||||
} else {
|
||||
builder.append("; ");
|
||||
return builder.append(a.getDescription());
|
||||
}
|
||||
});
|
||||
viewData.getAttachments(),
|
||||
new StringBuilder(),
|
||||
(builder, a) -> {
|
||||
if (a.getDescription() == null) {
|
||||
String placeholder =
|
||||
context.getString(R.string.description_post_media_no_description_placeholder);
|
||||
return builder.append(placeholder);
|
||||
} else {
|
||||
builder.append("; ");
|
||||
return builder.append(a.getDescription());
|
||||
}
|
||||
});
|
||||
return context.getString(R.string.description_post_media, mediaDescriptions);
|
||||
}
|
||||
|
||||
|
|
@ -918,7 +990,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) {
|
||||
@NonNull
|
||||
protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) {
|
||||
|
||||
if (visibility == null) {
|
||||
return "";
|
||||
|
|
@ -947,7 +1020,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
|
||||
Context context,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
|
||||
PollViewData poll = PollViewDataKt.toViewData(status.getPoll());
|
||||
if (poll == null) {
|
||||
return "";
|
||||
} else {
|
||||
|
|
@ -962,27 +1035,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions,
|
||||
context);
|
||||
context);
|
||||
return context.getString(R.string.description_poll, args);
|
||||
}
|
||||
}
|
||||
|
||||
protected CharSequence getFavsText(Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
@NonNull
|
||||
protected CharSequence getFavsText(@NonNull Context context, int count) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
}
|
||||
|
||||
protected CharSequence getReblogsText(Context context, int count) {
|
||||
if (count > 0) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
@NonNull
|
||||
protected CharSequence getReblogsText(@NonNull Context context, int count) {
|
||||
String countString = numberFormat.format(count);
|
||||
return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
}
|
||||
|
||||
private void setupPoll(PollViewData poll, List<Emoji> emojis,
|
||||
|
|
@ -1005,26 +1072,26 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
};
|
||||
pollAdapter.setup(
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
PollAdapter.RESULT,
|
||||
viewThreadListener,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
PollAdapter.RESULT,
|
||||
viewThreadListener,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
|
||||
pollButton.setVisibility(View.GONE);
|
||||
} else {
|
||||
// voting possible
|
||||
pollAdapter.setup(
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE,
|
||||
null,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
poll.getOptions(),
|
||||
poll.getVotesCount(),
|
||||
poll.getVotersCount(),
|
||||
emojis,
|
||||
poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE,
|
||||
null,
|
||||
statusDisplayOptions.animateEmojis()
|
||||
);
|
||||
|
||||
pollButton.setVisibility(View.VISIBLE);
|
||||
|
|
@ -1077,11 +1144,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected void setupCard(
|
||||
final StatusViewData.Concrete status,
|
||||
boolean expanded,
|
||||
final CardViewMode cardViewMode,
|
||||
final StatusDisplayOptions statusDisplayOptions,
|
||||
final StatusActionListener listener
|
||||
final @NonNull StatusViewData.Concrete status,
|
||||
boolean expanded,
|
||||
final @NonNull CardViewMode cardViewMode,
|
||||
final @NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
final @NonNull StatusActionListener listener
|
||||
) {
|
||||
if (cardView == null) {
|
||||
return;
|
||||
|
|
@ -1095,7 +1162,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
actionable.getPoll() == null &&
|
||||
card != null &&
|
||||
!TextUtils.isEmpty(card.getUrl()) &&
|
||||
(!actionable.getSensitive() || expanded) &&
|
||||
(TextUtils.isEmpty(actionable.getSpoilerText()) || expanded) &&
|
||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||
|
||||
cardView.setVisibility(View.VISIBLE);
|
||||
|
|
@ -1119,14 +1186,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
|
||||
|
||||
int radius = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder();
|
||||
|
||||
if (card.getWidth() > card.getHeight()) {
|
||||
cardView.setOrientation(LinearLayout.VERTICAL);
|
||||
|
||||
cardImage.getLayoutParams().height = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_vertical_height);
|
||||
.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;
|
||||
|
|
@ -1136,7 +1203,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardView.setOrientation(LinearLayout.HORIZONTAL);
|
||||
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
.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);
|
||||
|
|
@ -1148,40 +1215,40 @@ 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())
|
||||
.dontTransform();
|
||||
if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
|
||||
builder = builder.placeholder(decodeBlurHash(card.getBlurhash()));
|
||||
}
|
||||
builder.into(cardImage);
|
||||
} else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
|
||||
int radius = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
.getDimensionPixelSize(R.dimen.card_radius);
|
||||
|
||||
cardView.setOrientation(LinearLayout.HORIZONTAL);
|
||||
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
.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)
|
||||
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
|
||||
.build();
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
|
||||
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
|
||||
.build();
|
||||
cardImage.setShapeAppearanceModel(cardImageShape);
|
||||
|
||||
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
|
||||
Glide.with(cardImage.getContext())
|
||||
.load(decodeBlurHash(card.getBlurhash()))
|
||||
.dontTransform()
|
||||
.into(cardImage);
|
||||
.load(decodeBlurHash(card.getBlurhash()))
|
||||
.dontTransform()
|
||||
.into(cardImage);
|
||||
} else {
|
||||
cardView.setOrientation(LinearLayout.HORIZONTAL);
|
||||
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
|
||||
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
|
||||
|
|
@ -1190,8 +1257,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
cardImage.setScaleType(ImageView.ScaleType.CENTER);
|
||||
|
||||
Glide.with(cardImage.getContext())
|
||||
.load(R.drawable.card_image_placeholder)
|
||||
.into(cardImage);
|
||||
.load(R.drawable.card_image_placeholder)
|
||||
.into(cardImage);
|
||||
}
|
||||
|
||||
View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl());
|
||||
|
|
@ -1199,8 +1266,8 @@ 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()) ?
|
||||
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
|
||||
visitLink);
|
||||
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
|
||||
visitLink);
|
||||
|
||||
cardView.setClipToOutline(true);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ 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;
|
||||
|
|
@ -23,6 +25,7 @@ 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;
|
||||
|
|
@ -35,7 +38,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
|
||||
|
||||
public StatusDetailedViewHolder(View view) {
|
||||
public StatusDetailedViewHolder(@NonNull View view) {
|
||||
super(view);
|
||||
reblogs = view.findViewById(R.id.status_reblogs);
|
||||
favourites = view.findViewById(R.id.status_favourites);
|
||||
|
|
@ -43,7 +46,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
|
||||
|
||||
Status status = statusViewData.getActionable();
|
||||
|
||||
|
|
@ -57,8 +60,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
if (visibilityIcon != null) {
|
||||
ImageSpan visibilityIconSpan = new ImageSpan(
|
||||
visibilityIcon,
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
|
||||
visibilityIcon,
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
|
||||
);
|
||||
sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
|
@ -67,7 +70,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
Date createdAt = status.getCreatedAt();
|
||||
if (createdAt != null) {
|
||||
|
||||
sb.append(" ");
|
||||
sb.append(dateFormat.format(createdAt));
|
||||
}
|
||||
|
|
@ -95,10 +97,16 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
String language = status.getLanguage();
|
||||
|
||||
if (language != null) {
|
||||
sb.append(metadataJoiner);
|
||||
sb.append(language.toUpperCase());
|
||||
}
|
||||
|
||||
Status.Application app = status.getApplication();
|
||||
|
||||
if (app != null) {
|
||||
|
||||
sb.append(metadataJoiner);
|
||||
|
||||
if (app.getWebsite() != null) {
|
||||
|
|
@ -114,25 +122,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
|
||||
|
||||
if (reblogCount > 0) {
|
||||
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
|
||||
reblogs.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
reblogs.setVisibility(View.GONE);
|
||||
}
|
||||
if (favCount > 0) {
|
||||
favourites.setText(getFavsText(favourites.getContext(), favCount));
|
||||
favourites.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
favourites.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
|
||||
infoDivider.setVisibility(View.GONE);
|
||||
} else {
|
||||
infoDivider.setVisibility(View.VISIBLE);
|
||||
}
|
||||
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
|
||||
favourites.setText(getFavsText(favourites.getContext(), favCount));
|
||||
|
||||
reblogs.setOnClickListener(v -> {
|
||||
int position = getBindingAdapterPosition();
|
||||
|
|
@ -155,8 +146,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
@Nullable Object payloads) {
|
||||
// We never collapse statuses in the detail view
|
||||
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
|
||||
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
||||
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
|
|
@ -165,7 +156,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
if (!statusDisplayOptions.hideStats()) {
|
||||
setReblogAndFavCount(actionable.getReblogsCount(),
|
||||
actionable.getFavouritesCount(), listener);
|
||||
actionable.getFavouritesCount(), listener);
|
||||
} else {
|
||||
hideQuantitativeStats();
|
||||
}
|
||||
|
|
@ -197,7 +188,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
final Drawable visibilityDrawable = AppCompatResources.getDrawable(
|
||||
this.metaInfo.getContext(), visibilityIcon
|
||||
this.metaInfo.getContext(), visibilityIcon
|
||||
);
|
||||
if (visibilityDrawable == null) {
|
||||
return null;
|
||||
|
|
@ -205,10 +196,10 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
final int size = (int) this.metaInfo.getTextSize();
|
||||
visibilityDrawable.setBounds(
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size
|
||||
);
|
||||
visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor());
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
private final TextView favouritedCountLabel;
|
||||
private final TextView reblogsCountLabel;
|
||||
|
||||
public StatusViewHolder(View itemView) {
|
||||
public StatusViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
statusInfo = itemView.findViewById(R.id.status_info);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ class TabAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ViewBinding> {
|
||||
val binding = if (small) {
|
||||
ItemTabPreferenceSmallBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
ItemTabPreferenceSmallBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
} else {
|
||||
ItemTabPreferenceBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class CacheUpdater @Inject constructor(
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager,
|
||||
appDatabase: AppDatabase,
|
||||
gson: Gson
|
||||
appDatabase: AppDatabase
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
|
@ -26,22 +24,20 @@ class CacheUpdater @Inject constructor(
|
|||
eventHub.events.collect { event ->
|
||||
val accountId = accountManager.activeAccount?.id ?: return@collect
|
||||
when (event) {
|
||||
is FavoriteEvent ->
|
||||
timelineDao.setFavourited(accountId, event.statusId, event.favourite)
|
||||
is ReblogEvent ->
|
||||
timelineDao.setReblogged(accountId, event.statusId, event.reblog)
|
||||
is BookmarkEvent ->
|
||||
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
|
||||
is StatusChangedEvent -> {
|
||||
val status = event.status
|
||||
timelineDao.update(
|
||||
accountId = accountId,
|
||||
status = status
|
||||
)
|
||||
}
|
||||
is UnfollowEvent ->
|
||||
timelineDao.removeAllByUser(accountId, event.accountId)
|
||||
is StatusDeletedEvent ->
|
||||
timelineDao.delete(accountId, event.statusId)
|
||||
is PollVoteEvent -> {
|
||||
val pollString = gson.toJson(event.poll)
|
||||
timelineDao.setVoted(accountId, event.statusId, pollString)
|
||||
timelineDao.setVoted(accountId, event.statusId, event.poll)
|
||||
}
|
||||
is PinEvent ->
|
||||
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,29 @@ 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 FavoriteEvent(val statusId: String, val favourite: Boolean) : Event
|
||||
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event
|
||||
data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event
|
||||
data class StatusChangedEvent(val status: Status) : Event
|
||||
data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event
|
||||
data class UnfollowEvent(val accountId: String) : Event
|
||||
data class BlockEvent(val accountId: String) : Event
|
||||
data class MuteEvent(val accountId: String) : Event
|
||||
data class StatusDeletedEvent(val statusId: String) : Event
|
||||
data class StatusComposedEvent(val status: Status) : Event
|
||||
data class StatusScheduledEvent(val status: Status) : Event
|
||||
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
|
||||
data class StatusScheduledEvent(val scheduledStatus: ScheduledStatus) : Event
|
||||
data class ProfileEditedEvent(val newProfileData: Account) : Event
|
||||
data class PreferenceChangedEvent(val preferenceKey: String) : Event
|
||||
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Event
|
||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
|
||||
data class DomainMuteEvent(val instance: String) : Event
|
||||
data class AnnouncementReadEvent(val announcementId: String) : Event
|
||||
data class PinEvent(val statusId: String, val pinned: Boolean) : Event
|
||||
data class FilterUpdatedEvent(val filterContext: List<String>) : Event
|
||||
data class NewNotificationsEvent(
|
||||
val accountId: String,
|
||||
val notifications: List<Notification>
|
||||
) : Event
|
||||
data class ConversationsLoadingEvent(val accountId: String) : Event
|
||||
data class NotificationsLoadingEvent(val accountId: String) : Event
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
package com.keylesspalace.tusky.appstore
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
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
|
||||
|
||||
@Singleton
|
||||
class EventHub @Inject constructor() {
|
||||
|
||||
private val sharedEventFlow: MutableSharedFlow<Event> = MutableSharedFlow()
|
||||
val events: Flow<Event> = sharedEventFlow
|
||||
private val _events = MutableSharedFlow<Event>()
|
||||
val events: SharedFlow<Event> = _events.asSharedFlow()
|
||||
|
||||
suspend fun dispatch(event: Event) {
|
||||
sharedEventFlow.emit(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.TextWatcher
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
|
|
@ -32,10 +34,12 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
|
|
@ -43,11 +47,13 @@ import androidx.core.view.WindowInsetsCompat
|
|||
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||
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.appbar.AppBarLayout
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
|
|
@ -60,7 +66,7 @@ import com.keylesspalace.tusky.EditProfileActivity
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
|
||||
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
|
|
@ -86,6 +92,7 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
|||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
|
@ -102,6 +109,7 @@ import java.text.SimpleDateFormat
|
|||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener {
|
||||
|
||||
|
|
@ -173,9 +181,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
|
||||
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false)
|
||||
animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
hideFab = sharedPrefs.getBoolean("fabHide", false)
|
||||
hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
|
||||
handleWindowInsets()
|
||||
setupToolbar()
|
||||
|
|
@ -261,9 +269,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
binding.accountFragmentViewPager.adapter = adapter
|
||||
binding.accountFragmentViewPager.offscreenPageLimit = 2
|
||||
|
||||
val pageTitles = arrayOf(getString(R.string.title_posts), getString(R.string.title_posts_with_replies), getString(R.string.title_posts_pinned), getString(R.string.title_media))
|
||||
val pageTitles =
|
||||
arrayOf(
|
||||
getString(R.string.title_posts),
|
||||
getString(R.string.title_posts_with_replies),
|
||||
getString(R.string.title_posts_pinned),
|
||||
getString(R.string.title_media)
|
||||
)
|
||||
|
||||
TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position ->
|
||||
TabLayoutMediator(
|
||||
binding.accountTabLayout,
|
||||
binding.accountFragmentViewPager
|
||||
) { tab, position ->
|
||||
tab.text = pageTitles[position]
|
||||
}.attach()
|
||||
|
||||
|
|
@ -295,7 +312,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
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.accountCoordinatorLayout.updatePadding(
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
left = left
|
||||
)
|
||||
binding.swipeToRefreshLayout.setProgressViewEndTarget(
|
||||
false,
|
||||
top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance)
|
||||
)
|
||||
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
|
|
@ -312,30 +337,24 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
|
||||
val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation)
|
||||
|
||||
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation)
|
||||
val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(
|
||||
this,
|
||||
appBarElevation
|
||||
)
|
||||
toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
binding.accountToolbar.background = toolbarBackground
|
||||
|
||||
// Provide a non-transparent background to the navigation and overflow icons to ensure
|
||||
// they remain visible over whatever the profile background image might be.
|
||||
val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!!
|
||||
backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere
|
||||
binding.accountToolbar.navigationIcon = LayerDrawable(
|
||||
arrayOf(
|
||||
backgroundCircle,
|
||||
binding.accountToolbar.navigationIcon
|
||||
)
|
||||
)
|
||||
binding.accountToolbar.overflowIcon = LayerDrawable(
|
||||
arrayOf(
|
||||
backgroundCircle,
|
||||
binding.accountToolbar.overflowIcon
|
||||
)
|
||||
binding.accountToolbar.setNavigationIcon(R.drawable.ic_arrow_back_with_background)
|
||||
binding.accountToolbar.setOverflowIcon(
|
||||
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.createWithElevationOverlay(
|
||||
this,
|
||||
appBarElevation
|
||||
).apply {
|
||||
fillColor = ColorStateList.valueOf(toolbarColor)
|
||||
elevation = appBarElevation
|
||||
shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
|
|
@ -375,11 +394,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
|
||||
binding.accountAvatarImageView.visible(scaledAvatarSize > 0)
|
||||
|
||||
val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(1f)
|
||||
val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(
|
||||
1f
|
||||
)
|
||||
|
||||
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
|
||||
|
||||
val evaluatedToolbarColor = argbEvaluator.evaluate(transparencyPercent, Color.TRANSPARENT, toolbarColor) as Int
|
||||
val evaluatedToolbarColor = argbEvaluator.evaluate(
|
||||
transparencyPercent,
|
||||
Color.TRANSPARENT,
|
||||
toolbarColor
|
||||
) as Int
|
||||
|
||||
toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor)
|
||||
|
||||
|
|
@ -397,31 +422,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
* Subscribe to data loaded at the view model
|
||||
*/
|
||||
private fun subscribeObservables() {
|
||||
viewModel.accountData.observe(this) {
|
||||
when (it) {
|
||||
is Success -> onAccountChanged(it.data)
|
||||
is Error -> {
|
||||
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
|
||||
lifecycleScope.launch {
|
||||
viewModel.accountData.collect {
|
||||
if (it == null) return@collect
|
||||
when (it) {
|
||||
is Success -> onAccountChanged(it.data)
|
||||
is Error -> {
|
||||
Snackbar.make(
|
||||
binding.accountCoordinatorLayout,
|
||||
R.string.error_generic,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) { viewModel.refresh() }
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.relationshipData.collect {
|
||||
val relation = it?.data
|
||||
if (relation != null) {
|
||||
onRelationshipChanged(relation)
|
||||
}
|
||||
|
||||
if (it is Error) {
|
||||
Snackbar.make(
|
||||
binding.accountCoordinatorLayout,
|
||||
R.string.error_generic,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) { viewModel.refresh() }
|
||||
.show()
|
||||
}
|
||||
is Loading -> { }
|
||||
}
|
||||
}
|
||||
viewModel.relationshipData.observe(this) {
|
||||
val relation = it?.data
|
||||
if (relation != null) {
|
||||
onRelationshipChanged(relation)
|
||||
lifecycleScope.launch {
|
||||
viewModel.noteSaved.collect {
|
||||
binding.saveNoteInfo.visible(it, View.INVISIBLE)
|
||||
}
|
||||
|
||||
if (it is Error) {
|
||||
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) { viewModel.refresh() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
viewModel.noteSaved.observe(this) {
|
||||
binding.saveNoteInfo.visible(it, View.INVISIBLE)
|
||||
}
|
||||
|
||||
// "Post failed" dialog should display in this activity
|
||||
|
|
@ -438,10 +478,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
*/
|
||||
private fun setupRefreshLayout() {
|
||||
binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() }
|
||||
viewModel.isRefreshing.observe(
|
||||
this
|
||||
) { isRefreshing ->
|
||||
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
|
||||
lifecycleScope.launch {
|
||||
viewModel.isRefreshing.collect { isRefreshing ->
|
||||
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
|
||||
}
|
||||
}
|
||||
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
}
|
||||
|
|
@ -460,25 +500,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
val fullUsername = getFullUsername(loadedAccount)
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername))
|
||||
Snackbar.make(binding.root, getString(R.string.account_username_copied), Snackbar.LENGTH_SHORT)
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.account_username_copied),
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
|
||||
account.emojis,
|
||||
binding.accountNoteTextView,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
|
||||
|
||||
accountFieldAdapter.fields = account.fields.orEmpty()
|
||||
accountFieldAdapter.emojis = account.emojis.orEmpty()
|
||||
accountFieldAdapter.fields = account.fields
|
||||
accountFieldAdapter.emojis = account.emojis
|
||||
accountFieldAdapter.notifyDataSetChanged()
|
||||
|
||||
binding.accountLockedImageView.visible(account.locked)
|
||||
binding.accountBadgeTextView.visible(account.bot)
|
||||
|
||||
updateAccountAvatar()
|
||||
updateToolbar()
|
||||
updateBadges()
|
||||
updateMovedAccount()
|
||||
updateRemoteAccount()
|
||||
updateAccountJoinedDate()
|
||||
|
|
@ -491,6 +539,39 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateBadges() {
|
||||
binding.accountBadgeContainer.removeAllViews()
|
||||
|
||||
val isLight = resources.getBoolean(R.bool.lightNavigationBar)
|
||||
|
||||
if (loadedAccount?.bot == true) {
|
||||
val badgeView =
|
||||
getBadge(
|
||||
getColor(R.color.tusky_grey_50),
|
||||
R.drawable.ic_bot_24dp,
|
||||
getString(R.string.profile_badge_bot_text),
|
||||
isLight
|
||||
)
|
||||
binding.accountBadgeContainer.addView(badgeView)
|
||||
}
|
||||
|
||||
loadedAccount?.roles?.forEach { role ->
|
||||
val badgeColor = if (role.color.isNotBlank()) {
|
||||
Color.parseColor(role.color)
|
||||
} else {
|
||||
// sometimes the color is not set for a role, in this case fall back to our default blue
|
||||
getColor(R.color.tusky_blue)
|
||||
}
|
||||
|
||||
val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}")
|
||||
sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0)
|
||||
|
||||
val badgeView = getBadge(badgeColor, R.drawable.profile_badge_person_24dp, sb, isLight)
|
||||
|
||||
binding.accountBadgeContainer.addView(badgeView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAccountJoinedDate() {
|
||||
loadedAccount?.let { account ->
|
||||
try {
|
||||
|
|
@ -579,7 +660,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
*/
|
||||
private fun updateRemoteAccount() {
|
||||
loadedAccount?.let { account ->
|
||||
if (account.isRemote()) {
|
||||
if (account.isRemote) {
|
||||
binding.accountRemoveView.show()
|
||||
binding.accountRemoveView.setOnClickListener {
|
||||
openLink(account.url)
|
||||
|
|
@ -772,13 +853,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
loadedAccount?.let { loadedAccount ->
|
||||
val muteDomain = menu.findItem(R.id.action_mute_domain)
|
||||
domain = getDomain(loadedAccount.url)
|
||||
if (domain.isEmpty()) {
|
||||
when {
|
||||
// If we can't get the domain, there's no way we can mute it anyway...
|
||||
menu.removeItem(R.id.action_mute_domain)
|
||||
} else {
|
||||
if (blockingDomain) {
|
||||
// If the account is from our own domain, muting it is no-op
|
||||
domain.isEmpty() || viewModel.isFromOwnDomain -> {
|
||||
menu.removeItem(R.id.action_mute_domain)
|
||||
}
|
||||
blockingDomain -> {
|
||||
muteDomain.title = getString(R.string.action_unmute_domain, domain)
|
||||
} else {
|
||||
}
|
||||
else -> {
|
||||
muteDomain.title = getString(R.string.action_mute_domain, domain)
|
||||
}
|
||||
}
|
||||
|
|
@ -837,7 +921,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
} else {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.mute_domain_warning, instance))
|
||||
.setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) }
|
||||
.setPositiveButton(
|
||||
getString(R.string.mute_domain_warning_dialog_ok)
|
||||
) { _, _ -> viewModel.blockDomain(instance) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
|
@ -930,7 +1016,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, url)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_link_to)))
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
sendIntent,
|
||||
resources.getText(R.string.send_account_link_to)
|
||||
)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -942,7 +1033,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, fullUsername)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_username_to)))
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
sendIntent,
|
||||
resources.getText(R.string.send_account_username_to)
|
||||
)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -955,7 +1051,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
return true
|
||||
}
|
||||
R.id.action_add_or_remove_from_list -> {
|
||||
ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
|
||||
ListSelectionFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
|
||||
return true
|
||||
}
|
||||
R.id.action_mute_domain -> {
|
||||
|
|
@ -973,7 +1069,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
R.id.action_report -> {
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
|
||||
startActivity(
|
||||
ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -990,7 +1088,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
|
||||
private fun getFullUsername(account: Account): String {
|
||||
return if (account.isRemote()) {
|
||||
return if (account.isRemote) {
|
||||
"@" + account.username
|
||||
} else {
|
||||
val localUsername = account.localUsername
|
||||
|
|
@ -1000,6 +1098,51 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
|
|||
}
|
||||
}
|
||||
|
||||
private fun getBadge(
|
||||
@ColorInt baseColor: Int,
|
||||
@DrawableRes icon: Int,
|
||||
text: CharSequence,
|
||||
isLight: Boolean
|
||||
): Chip {
|
||||
val badge = Chip(this)
|
||||
|
||||
// text color with maximum contrast
|
||||
val textColor = if (isLight) Color.BLACK else Color.WHITE
|
||||
// badge color with 50% transparency so it blends in with the theme background
|
||||
val backgroundColor = Color.argb(
|
||||
128,
|
||||
Color.red(baseColor),
|
||||
Color.green(baseColor),
|
||||
Color.blue(baseColor)
|
||||
)
|
||||
// a color between the text color and the badge color
|
||||
val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f)
|
||||
|
||||
// configure the badge
|
||||
badge.text = text
|
||||
badge.setTextColor(textColor)
|
||||
badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width)
|
||||
badge.chipStrokeColor = ColorStateList.valueOf(outlineColor)
|
||||
badge.setChipIconResource(icon)
|
||||
badge.isChipIconVisible = true
|
||||
badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size)
|
||||
badge.chipIconTint = ColorStateList.valueOf(outlineColor)
|
||||
badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor)
|
||||
|
||||
// badge isn't clickable, so disable all related behavior
|
||||
badge.isClickable = false
|
||||
badge.isFocusable = false
|
||||
badge.setEnsureMinTouchTargetSize(false)
|
||||
|
||||
// reset some chip defaults so it looks better for our badge usecase
|
||||
badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding)
|
||||
badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding)
|
||||
badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height)
|
||||
badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height)
|
||||
badge.updatePadding(top = 0, bottom = 0)
|
||||
return badge
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -38,8 +38,15 @@ class AccountFieldAdapter(
|
|||
|
||||
override fun getItemCount() = fields.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAccountFieldBinding> {
|
||||
val binding = ItemAccountFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAccountFieldBinding> {
|
||||
val binding = ItemAccountFieldBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
|
|
@ -51,11 +58,20 @@ class AccountFieldAdapter(
|
|||
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
|
||||
nameTextView.text = emojifiedName
|
||||
|
||||
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
|
||||
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(
|
||||
emojis,
|
||||
valueTextView,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
|
||||
|
||||
if (field.verifiedAt != null) {
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
0,
|
||||
0,
|
||||
R.drawable.ic_check_circle,
|
||||
0
|
||||
)
|
||||
} else {
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ class AccountPagerAdapter(
|
|||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
|
||||
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
|
||||
1 -> TimelineFragment.newInstance(
|
||||
TimelineViewModel.Kind.USER_WITH_REPLIES,
|
||||
accountId,
|
||||
false
|
||||
)
|
||||
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
|
||||
3 -> AccountMediaFragment.newInstance(accountId)
|
||||
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.keylesspalace.tusky.components.account
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
|
|
@ -19,58 +18,83 @@ 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 kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.keylesspalace.tusky.util.getDomain
|
||||
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
|
||||
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val accountManager: AccountManager
|
||||
accountManager: AccountManager
|
||||
) : ViewModel() {
|
||||
|
||||
val accountData = MutableLiveData<Resource<Account>>()
|
||||
val relationshipData = MutableLiveData<Resource<Relationship>>()
|
||||
private val _accountData = MutableStateFlow(null as Resource<Account>?)
|
||||
val accountData: StateFlow<Resource<Account>?> = _accountData.asStateFlow()
|
||||
|
||||
val noteSaved = MutableLiveData<Boolean>()
|
||||
private val _relationshipData = MutableStateFlow(null as Resource<Relationship>?)
|
||||
val relationshipData: StateFlow<Resource<Relationship>?> = _relationshipData.asStateFlow()
|
||||
|
||||
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()
|
||||
|
||||
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
|
||||
private var isDataLoading = false
|
||||
|
||||
lateinit var accountId: String
|
||||
var isSelf = false
|
||||
|
||||
/** the domain of the viewed account **/
|
||||
var domain = ""
|
||||
|
||||
/** True if the viewed account has the same domain as the active account */
|
||||
var isFromOwnDomain = false
|
||||
|
||||
private var noteUpdateJob: Job? = null
|
||||
|
||||
private val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
eventHub.events.collect { event ->
|
||||
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
|
||||
accountData.postValue(Success(event.newProfileData))
|
||||
if (event is ProfileEditedEvent && event.newProfileData.id == _accountData.value?.data?.id) {
|
||||
_accountData.value = Success(event.newProfileData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainAccount(reload: Boolean = false) {
|
||||
if (accountData.value == null || reload) {
|
||||
if (_accountData.value == null || reload) {
|
||||
isDataLoading = true
|
||||
accountData.postValue(Loading())
|
||||
_accountData.value = Loading()
|
||||
|
||||
viewModelScope.launch {
|
||||
mastodonApi.account(accountId)
|
||||
.fold(
|
||||
{ account ->
|
||||
accountData.postValue(Success(account))
|
||||
domain = getDomain(account.url)
|
||||
isFromOwnDomain = domain == activeAccount.domain
|
||||
|
||||
_accountData.value = Success(account)
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
_isRefreshing.emit(false)
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining account", t)
|
||||
accountData.postValue(Error(cause = t))
|
||||
_accountData.value = Error(cause = t)
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
_isRefreshing.emit(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -78,18 +102,25 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun obtainRelationship(reload: Boolean = false) {
|
||||
if (relationshipData.value == null || reload) {
|
||||
relationshipData.postValue(Loading())
|
||||
if (_relationshipData.value == null || reload) {
|
||||
_relationshipData.value = Loading()
|
||||
|
||||
viewModelScope.launch {
|
||||
mastodonApi.relationships(listOf(accountId))
|
||||
.fold(
|
||||
{ relationships ->
|
||||
relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error())
|
||||
_relationshipData.value =
|
||||
if (relationships.isNotEmpty()) {
|
||||
Success(
|
||||
relationships[0]
|
||||
)
|
||||
} else {
|
||||
Error()
|
||||
}
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining relationships", t)
|
||||
relationshipData.postValue(Error(cause = t))
|
||||
_relationshipData.value = Error(cause = t)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -97,7 +128,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeFollowState() {
|
||||
val relationship = relationshipData.value?.data
|
||||
val relationship = _relationshipData.value?.data
|
||||
if (relationship?.following == true || relationship?.requested == true) {
|
||||
changeRelationship(RelationShipAction.UNFOLLOW)
|
||||
} else {
|
||||
|
|
@ -106,7 +137,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeBlockState() {
|
||||
if (relationshipData.value?.data?.blocking == true) {
|
||||
if (_relationshipData.value?.data?.blocking == true) {
|
||||
changeRelationship(RelationShipAction.UNBLOCK)
|
||||
} else {
|
||||
changeRelationship(RelationShipAction.BLOCK)
|
||||
|
|
@ -122,9 +153,9 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeSubscribingState() {
|
||||
val relationship = relationshipData.value?.data
|
||||
if (relationship?.notifying == true || /* Mastodon 3.3.0rc1 */
|
||||
relationship?.subscribing == true /* Pleroma */
|
||||
val relationship = _relationshipData.value?.data
|
||||
if (relationship?.notifying == true || // Mastodon 3.3.0rc1
|
||||
relationship?.subscribing == true // Pleroma
|
||||
) {
|
||||
changeRelationship(RelationShipAction.UNSUBSCRIBE)
|
||||
} else {
|
||||
|
|
@ -136,9 +167,9 @@ class AccountViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
mastodonApi.blockDomain(instance).fold({
|
||||
eventHub.dispatch(DomainMuteEvent(instance))
|
||||
val relation = relationshipData.value?.data
|
||||
val relation = _relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = true)))
|
||||
_relationshipData.value = Success(relation.copy(blockingDomain = true))
|
||||
}
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error muting $instance", e)
|
||||
|
|
@ -149,9 +180,9 @@ class AccountViewModel @Inject constructor(
|
|||
fun unblockDomain(instance: String) {
|
||||
viewModelScope.launch {
|
||||
mastodonApi.unblockDomain(instance).fold({
|
||||
val relation = relationshipData.value?.data
|
||||
val relation = _relationshipData.value?.data
|
||||
if (relation != null) {
|
||||
relationshipData.postValue(Success(relation.copy(blockingDomain = false)))
|
||||
_relationshipData.value = Success(relation.copy(blockingDomain = false))
|
||||
}
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error unmuting $instance", e)
|
||||
|
|
@ -160,7 +191,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun changeShowReblogsState() {
|
||||
if (relationshipData.value?.data?.showingReblogs == true) {
|
||||
if (_relationshipData.value?.data?.showingReblogs == true) {
|
||||
changeRelationship(RelationShipAction.FOLLOW, false)
|
||||
} else {
|
||||
changeRelationship(RelationShipAction.FOLLOW, true)
|
||||
|
|
@ -175,9 +206,9 @@ class AccountViewModel @Inject constructor(
|
|||
parameter: Boolean? = null,
|
||||
duration: Int? = null
|
||||
) = viewModelScope.launch {
|
||||
val relation = relationshipData.value?.data
|
||||
val account = accountData.value?.data
|
||||
val isMastodon = relationshipData.value?.data?.notifying != null
|
||||
val relation = _relationshipData.value?.data
|
||||
val account = _accountData.value?.data
|
||||
val isMastodon = _relationshipData.value?.data?.notifying != null
|
||||
|
||||
if (relation != null && account != null) {
|
||||
// optimistically post new state for faster response
|
||||
|
|
@ -210,7 +241,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
relationshipData.postValue(Loading(newRelation))
|
||||
_relationshipData.value = Loading(newRelation)
|
||||
}
|
||||
|
||||
val relationshipCall = when (relationshipAction) {
|
||||
|
|
@ -245,7 +276,7 @@ class AccountViewModel @Inject constructor(
|
|||
|
||||
relationshipCall.fold(
|
||||
{ relationship ->
|
||||
relationshipData.postValue(Success(relationship))
|
||||
_relationshipData.value = Success(relationship)
|
||||
|
||||
when (relationshipAction) {
|
||||
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
||||
|
|
@ -256,22 +287,22 @@ class AccountViewModel @Inject constructor(
|
|||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed loading relationship", t)
|
||||
relationshipData.postValue(Error(relation, cause = t))
|
||||
_relationshipData.value = Error(relation, cause = t)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun noteChanged(newNote: String) {
|
||||
noteSaved.postValue(false)
|
||||
_noteSaved.value = false
|
||||
noteUpdateJob?.cancel()
|
||||
noteUpdateJob = viewModelScope.launch {
|
||||
delay(1500)
|
||||
mastodonApi.updateAccountNote(accountId, newNote)
|
||||
.fold(
|
||||
{
|
||||
noteSaved.postValue(true)
|
||||
_noteSaved.value = true
|
||||
delay(4000)
|
||||
noteSaved.postValue(false)
|
||||
_noteSaved.value = false
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "Error updating note", t)
|
||||
|
|
@ -298,12 +329,19 @@ class AccountViewModel @Inject constructor(
|
|||
|
||||
fun setAccountInfo(accountId: String) {
|
||||
this.accountId = accountId
|
||||
this.isSelf = accountManager.activeAccount?.accountId == accountId
|
||||
this.isSelf = activeAccount.accountId == accountId
|
||||
reload(false)
|
||||
}
|
||||
|
||||
enum class RelationShipAction {
|
||||
FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE, SUBSCRIBE, UNSUBSCRIBE
|
||||
FOLLOW,
|
||||
UNFOLLOW,
|
||||
BLOCK,
|
||||
UNBLOCK,
|
||||
MUTE,
|
||||
UNMUTE,
|
||||
SUBSCRIBE,
|
||||
UNSUBSCRIBE
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
/* Copyright 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.account.list
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
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.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 kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ListSelectionFragment : DialogFragment(), Injectable {
|
||||
|
||||
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 var selectListener: ListSelectionListener? = null
|
||||
private var accountId: String? = null
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
selectListener = context as? ListSelectionListener
|
||||
}
|
||||
|
||||
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)
|
||||
binding.listsView.adapter = adapter
|
||||
|
||||
val dialogBuilder = AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.setTitle(R.string.select_list_title)
|
||||
.setNeutralButton(R.string.select_list_manage) { _, _ ->
|
||||
val listIntent = Intent(context, ListsActivity::class.java)
|
||||
startActivity(listIntent)
|
||||
}
|
||||
.setNegativeButton(if (accountId != null) R.string.button_done else android.R.string.cancel, null)
|
||||
|
||||
val dialog = dialogBuilder.create()
|
||||
|
||||
val showProgressBarJob = getProgressBarJob(binding.progressBar, 500)
|
||||
showProgressBarJob.start()
|
||||
|
||||
// TODO change this to a (single) LoadState like elsewhere?
|
||||
lifecycleScope.launch {
|
||||
viewModel.states.collectLatest { states ->
|
||||
binding.progressBar.hide()
|
||||
showProgressBarJob.cancel()
|
||||
if (states.isEmpty()) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists)
|
||||
} else {
|
||||
binding.listsView.show()
|
||||
adapter.submitList(states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.loadError.collectLatest { error ->
|
||||
Log.e(TAG, "failed to load lists", error)
|
||||
binding.progressBar.hide()
|
||||
showProgressBarJob.cancel()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.apply {
|
||||
show()
|
||||
setup(error) { load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.actionError.collectLatest { error ->
|
||||
when (error.type) {
|
||||
ActionError.Type.ADD -> {
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
R.string.failed_to_add_to_list,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.addAccountToList(accountId!!, error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
ActionError.Type.REMOVE -> {
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
R.string.failed_to_remove_from_list,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.removeAccountFromList(accountId!!, error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
load()
|
||||
}
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch(
|
||||
start = CoroutineStart.LAZY
|
||||
) {
|
||||
try {
|
||||
delay(delayMs)
|
||||
progressView.show()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
progressView.hide()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
binding.progressBar.show()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.hide()
|
||||
viewModel.load(accountId)
|
||||
}
|
||||
|
||||
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem.list.id == newItem.list.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
inner class Adapter :
|
||||
ListAdapter<AccountListState, BindingHolder<ItemListBinding>>(Differ) {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemListBinding> {
|
||||
return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemListBinding>, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.binding.listName.text = item.list.title
|
||||
accountId?.let { accountId ->
|
||||
holder.binding.addButton.apply {
|
||||
visible(!item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.addAccountToList(accountId, item.list.id)
|
||||
}
|
||||
}
|
||||
holder.binding.removeButton.apply {
|
||||
visible(item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.removeAccountFromList(accountId, item.list.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
selectListener?.onListSelected(item.list)
|
||||
|
||||
accountId?.let { accountId ->
|
||||
if (item.includesAccount) {
|
||||
viewModel.removeAccountFromList(accountId, item.list.id)
|
||||
} else {
|
||||
viewModel.addAccountToList(accountId, item.list.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ListsListFragment"
|
||||
private const val ARG_ACCOUNT_ID = "accountId"
|
||||
|
||||
fun newInstance(accountId: String?): ListSelectionFragment {
|
||||
val args = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_ID, accountId)
|
||||
}
|
||||
return ListSelectionFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
/* Copyright 2022 kyori19
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.account.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ListsForAccountFragment : DialogFragment(), Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
|
||||
private val binding by viewBinding(FragmentListsForAccountBinding::bind)
|
||||
|
||||
private val adapter = Adapter()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
|
||||
|
||||
viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.apply {
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_lists_for_account, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.listsView.layoutManager = LinearLayoutManager(view.context)
|
||||
binding.listsView.adapter = adapter
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.states.collectLatest { states ->
|
||||
binding.progressBar.hide()
|
||||
if (states.isEmpty()) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) {
|
||||
load()
|
||||
}
|
||||
} else {
|
||||
binding.listsView.show()
|
||||
adapter.submitList(states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.loadError.collectLatest { error ->
|
||||
binding.progressBar.hide()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.apply {
|
||||
show()
|
||||
setup(error) { load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.actionError.collectLatest { error ->
|
||||
when (error.type) {
|
||||
ActionError.Type.ADD -> {
|
||||
Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.addAccountToList(error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
ActionError.Type.REMOVE -> {
|
||||
Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) {
|
||||
viewModel.removeAccountFromList(error.listId)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.doneButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
binding.progressBar.show()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.hide()
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem.list.id == newItem.list.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: AccountListState,
|
||||
newItem: AccountListState
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
inner class Adapter :
|
||||
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAddOrRemoveFromListBinding> {
|
||||
val binding =
|
||||
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemAddOrRemoveFromListBinding>, position: Int) {
|
||||
val item = getItem(position)
|
||||
holder.binding.listNameView.text = item.list.title
|
||||
holder.binding.addButton.apply {
|
||||
visible(!item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.addAccountToList(item.list.id)
|
||||
}
|
||||
}
|
||||
holder.binding.removeButton.apply {
|
||||
visible(item.includesAccount)
|
||||
setOnClickListener {
|
||||
viewModel.removeAccountFromList(item.list.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_ACCOUNT_ID = "accountId"
|
||||
|
||||
fun newInstance(accountId: String): ListsForAccountFragment {
|
||||
val args = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_ID, accountId)
|
||||
}
|
||||
return ListsForAccountFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,14 +24,13 @@ 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 javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AccountListState(
|
||||
val list: MastoList,
|
||||
|
|
@ -54,35 +53,30 @@ class ListsForAccountViewModel @Inject constructor(
|
|||
private val mastodonApi: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
private lateinit var accountId: String
|
||||
|
||||
private val _states = MutableSharedFlow<List<AccountListState>>(1)
|
||||
val states: SharedFlow<List<AccountListState>> = _states
|
||||
val states: SharedFlow<List<AccountListState>> = _states.asSharedFlow()
|
||||
|
||||
private val _loadError = MutableSharedFlow<Throwable>(1)
|
||||
val loadError: SharedFlow<Throwable> = _loadError
|
||||
val loadError: SharedFlow<Throwable> = _loadError.asSharedFlow()
|
||||
|
||||
private val _actionError = MutableSharedFlow<ActionError>(1)
|
||||
val actionError: SharedFlow<ActionError> = _actionError
|
||||
val actionError: SharedFlow<ActionError> = _actionError.asSharedFlow()
|
||||
|
||||
fun setup(accountId: String) {
|
||||
this.accountId = accountId
|
||||
}
|
||||
|
||||
fun load() {
|
||||
fun load(accountId: String?) {
|
||||
_loadError.resetReplayCache()
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val (all, includes) = listOf(
|
||||
async { mastodonApi.getLists() },
|
||||
async { mastodonApi.getListsIncludesAccount(accountId) }
|
||||
).awaitAll()
|
||||
val all = mastodonApi.getLists().getOrThrow()
|
||||
var includes: List<MastoList> = emptyList()
|
||||
if (accountId != null) {
|
||||
includes = mastodonApi.getListsIncludesAccount(accountId).getOrThrow()
|
||||
}
|
||||
|
||||
_states.emit(
|
||||
all.getOrThrow().map { list ->
|
||||
all.map { listState ->
|
||||
AccountListState(
|
||||
list = list,
|
||||
includesAccount = includes.getOrThrow().any { it.id == list.id }
|
||||
list = listState,
|
||||
includesAccount = includes.any { it.id == listState.id }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -93,7 +87,9 @@ class ListsForAccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun addAccountToList(listId: String) {
|
||||
// TODO there is no "progress" visible for these
|
||||
|
||||
fun addAccountToList(accountId: String, listId: String) {
|
||||
_actionError.resetReplayCache()
|
||||
viewModelScope.launch {
|
||||
mastodonApi.addAccountToList(listId, listOf(accountId))
|
||||
|
|
@ -114,7 +110,7 @@ class ListsForAccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun removeAccountFromList(listId: String) {
|
||||
fun removeAccountFromList(accountId: String, listId: String) {
|
||||
_actionError.resetReplayCache()
|
||||
viewModelScope.launch {
|
||||
mastodonApi.deleteAccountFromList(listId, listOf(accountId))
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ 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 kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Fragment with multiple columns of media previews for the specified account.
|
||||
|
|
@ -82,22 +82,23 @@ class AccountMediaFragment :
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
|
||||
|
||||
adapter = AccountMediaGridAdapter(
|
||||
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
|
||||
useBlurhash = useBlurhash,
|
||||
context = view.context,
|
||||
onAttachmentClickListener = ::onAttachmentClick
|
||||
)
|
||||
|
||||
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
|
||||
val imageSpacing = view.context.resources.getDimensionPixelSize(R.dimen.profile_media_spacing)
|
||||
val imageSpacing = view.context.resources.getDimensionPixelSize(
|
||||
R.dimen.profile_media_spacing
|
||||
)
|
||||
|
||||
binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(columnCount, imageSpacing, 0))
|
||||
binding.recyclerView.addItemDecoration(
|
||||
GridSpacingItemDecoration(columnCount, imageSpacing, 0)
|
||||
)
|
||||
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
|
@ -127,7 +128,11 @@ class AccountMediaFragment :
|
|||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
is LoadState.Error -> {
|
||||
|
|
@ -178,11 +183,19 @@ class AccountMediaFragment :
|
|||
Attachment.Type.GIFV,
|
||||
Attachment.Type.VIDEO,
|
||||
Attachment.Type.AUDIO -> {
|
||||
val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex)
|
||||
val intent = ViewMediaActivity.newIntent(
|
||||
context,
|
||||
attachmentsFromSameStatus,
|
||||
currentIndex
|
||||
)
|
||||
if (activity != null) {
|
||||
val url = selected.attachment.url
|
||||
ViewCompat.setTransitionName(view, url)
|
||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url)
|
||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
|
||||
requireActivity(),
|
||||
view,
|
||||
url
|
||||
)
|
||||
startActivity(intent, options.toBundle())
|
||||
} else {
|
||||
startActivity(intent)
|
||||
|
|
|
|||
|
|
@ -21,36 +21,57 @@ import com.keylesspalace.tusky.util.getFormattedDescription
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import java.util.Random
|
||||
import kotlin.random.Random
|
||||
|
||||
class AccountMediaGridAdapter(
|
||||
private val alwaysShowSensitiveMedia: Boolean,
|
||||
private val useBlurhash: Boolean,
|
||||
context: Context,
|
||||
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
|
||||
) : PagingDataAdapter<AttachmentViewData, BindingHolder<ItemAccountMediaBinding>>(
|
||||
object : DiffUtil.ItemCallback<AttachmentViewData>() {
|
||||
override fun areItemsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AttachmentViewData,
|
||||
newItem: AttachmentViewData
|
||||
): Boolean {
|
||||
return oldItem.attachment.id == newItem.attachment.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: AttachmentViewData,
|
||||
newItem: AttachmentViewData
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||
private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
|
||||
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
|
||||
private val baseItemBackgroundColor = MaterialColors.getColor(
|
||||
context,
|
||||
com.google.android.material.R.attr.colorSurface,
|
||||
Color.BLACK
|
||||
)
|
||||
private val videoIndicator = AppCompatResources.getDrawable(
|
||||
context,
|
||||
R.drawable.ic_play_indicator
|
||||
)
|
||||
private val mediaHiddenDrawable = AppCompatResources.getDrawable(
|
||||
context,
|
||||
R.drawable.ic_hide_media_24dp
|
||||
)
|
||||
|
||||
private val itemBgBaseHSV = FloatArray(3)
|
||||
private val random = Random()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAccountMediaBinding> {
|
||||
val binding = ItemAccountMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAccountMediaBinding> {
|
||||
val binding = ItemAccountMediaBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV)
|
||||
itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f
|
||||
itemBgBaseHSV[2] = itemBgBaseHSV[2] + Random.nextFloat() / 3f - 1f / 6f
|
||||
binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV))
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
|
@ -72,7 +93,11 @@ class AccountMediaGridAdapter(
|
|||
if (item.attachment.type == Attachment.Type.AUDIO) {
|
||||
overlay.hide()
|
||||
|
||||
imageView.setPadding(context.resources.getDimensionPixelSize(R.dimen.profile_media_audio_icon_padding))
|
||||
imageView.setPadding(
|
||||
context.resources.getDimensionPixelSize(
|
||||
R.dimen.profile_media_audio_icon_padding
|
||||
)
|
||||
)
|
||||
|
||||
Glide.with(imageView)
|
||||
.load(R.drawable.ic_music_box_preview_24dp)
|
||||
|
|
@ -80,7 +105,7 @@ class AccountMediaGridAdapter(
|
|||
.into(imageView)
|
||||
|
||||
imageView.contentDescription = item.attachment.getFormattedDescription(context)
|
||||
} else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) {
|
||||
} else if (item.sensitive && !item.isRevealed) {
|
||||
overlay.show()
|
||||
overlay.setImageDrawable(mediaHiddenDrawable)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.paging.LoadType
|
|||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import retrofit2.HttpException
|
||||
|
|
@ -27,9 +28,9 @@ import retrofit2.HttpException
|
|||
@OptIn(ExperimentalPagingApi::class)
|
||||
class AccountMediaRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val activeAccount: AccountEntity,
|
||||
private val viewModel: AccountMediaViewModel
|
||||
) : RemoteMediator<String, AttachmentViewData>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<String, AttachmentViewData>
|
||||
|
|
@ -58,7 +59,7 @@ class AccountMediaRemoteMediator(
|
|||
}
|
||||
|
||||
val attachments = statuses.flatMap { status ->
|
||||
AttachmentViewData.list(status)
|
||||
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia)
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountMediaViewModel @Inject constructor(
|
||||
accountManager: AccountManager,
|
||||
api: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor(
|
|||
|
||||
var currentSource: AccountMediaPagingSource? = null
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val media = Pager(
|
||||
config = PagingConfig(
|
||||
|
|
@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor(
|
|||
currentSource = source
|
||||
}
|
||||
},
|
||||
remoteMediator = AccountMediaRemoteMediator(api, this)
|
||||
remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this)
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
|
||||
val id: String? = intent.getStringExtra(EXTRA_ID)
|
||||
val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false)
|
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
supportActionBar?.apply {
|
||||
|
|
@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
}
|
||||
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
|
||||
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
companion object {
|
||||
private const val EXTRA_TYPE = "type"
|
||||
private const val EXTRA_ID = "id"
|
||||
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
|
||||
|
||||
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
|
||||
fun newIntent(context: Context, type: Type, id: String? = null): Intent {
|
||||
return Intent(context, AccountListActivity::class.java).apply {
|
||||
putExtra(EXTRA_TYPE, type)
|
||||
putExtra(EXTRA_ID, id)
|
||||
putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import android.os.Bundle
|
|||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
|
|
@ -28,10 +27,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior
|
||||
import com.keylesspalace.tusky.R
|
||||
|
|
@ -56,13 +52,13 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountListFragment :
|
||||
Fragment(R.layout.fragment_account_list),
|
||||
|
|
@ -97,7 +93,9 @@ class AccountListFragment :
|
|||
val layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
|
||||
binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
|
||||
|
|
@ -107,15 +105,18 @@ class AccountListFragment :
|
|||
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
adapter = when (type) {
|
||||
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
Type.FOLLOW_REQUESTS -> {
|
||||
val headerAdapter = FollowRequestsHeaderAdapter(
|
||||
instanceName = accountManager.activeAccount!!.domain,
|
||||
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
|
||||
instanceName = activeAccount.domain,
|
||||
accountLocked = activeAccount.locked
|
||||
)
|
||||
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
val followRequestsAdapter =
|
||||
FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
|
||||
followRequestsAdapter
|
||||
}
|
||||
|
|
@ -140,15 +141,13 @@ class AccountListFragment :
|
|||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
(activity as BaseActivity?)
|
||||
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
activity?.startActivityWithSlideInAnimation(
|
||||
StatusListActivity.newHashtagIntent(requireContext(), tag)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
(activity as BaseActivity?)?.let {
|
||||
val intent = AccountActivity.getIntent(it, id)
|
||||
it.startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
activity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id))
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
|
|
@ -224,7 +223,11 @@ class AccountListFragment :
|
|||
val unblockedUser = blocksAdapter.removeItem(position)
|
||||
|
||||
if (unblockedUser != null) {
|
||||
Snackbar.make(binding.recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG)
|
||||
Snackbar.make(
|
||||
binding.recyclerView,
|
||||
R.string.confirmation_unblocked,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.action_undo) {
|
||||
blocksAdapter.addItem(unblockedUser, position)
|
||||
onBlock(true, id, position)
|
||||
|
|
@ -242,22 +245,17 @@ class AccountListFragment :
|
|||
Log.e(TAG, "Failed to $verb account accountId $accountId")
|
||||
}
|
||||
|
||||
override fun onRespondToFollowRequest(
|
||||
accept: Boolean,
|
||||
accountId: String,
|
||||
position: Int
|
||||
) {
|
||||
if (accept) {
|
||||
api.authorizeFollowRequest(accountId)
|
||||
} else {
|
||||
api.rejectFollowRequest(accountId)
|
||||
}.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{
|
||||
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
if (accept) {
|
||||
api.authorizeFollowRequest(accountId)
|
||||
} else {
|
||||
api.rejectFollowRequest(accountId)
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
onRespondToFollowRequestSuccess(position)
|
||||
},
|
||||
{ throwable ->
|
||||
onFailure = { throwable ->
|
||||
val verb = if (accept) {
|
||||
"accept"
|
||||
} else {
|
||||
|
|
@ -266,6 +264,7 @@ class AccountListFragment :
|
|||
Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRespondToFollowRequestSuccess(position: Int) {
|
||||
|
|
@ -330,7 +329,13 @@ class AccountListFragment :
|
|||
|
||||
val linkHeader = response.headers()["Link"]
|
||||
onFetchAccountsSuccess(accountList, linkHeader)
|
||||
} catch (exception: IOException) {
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) {
|
||||
// Scope is cancelled, probably because the fragment is destroyed.
|
||||
// We must not touch any views anymore, so rethrow the exception.
|
||||
// (CancellationException in a cancelled scope is normal and will be ignored)
|
||||
throw exception
|
||||
}
|
||||
onFetchAccountsFailure(exception)
|
||||
}
|
||||
}
|
||||
|
|
@ -404,14 +409,12 @@ class AccountListFragment :
|
|||
private const val TAG = "AccountList" // logging tag
|
||||
private const val ARG_TYPE = "type"
|
||||
private const val ARG_ID = "id"
|
||||
private const val ARG_ACCOUNT_LOCKED = "acc_locked"
|
||||
|
||||
fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment {
|
||||
fun newInstance(type: Type, id: String? = null): AccountListFragment {
|
||||
return AccountListFragment().apply {
|
||||
arguments = Bundle(3).apply {
|
||||
putSerializable(ARG_TYPE, type)
|
||||
putString(ARG_ID, id)
|
||||
putBoolean(ARG_ACCOUNT_LOCKED, accountLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,9 +60,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
|
|||
}
|
||||
}
|
||||
|
||||
private fun createFooterViewHolder(
|
||||
parent: ViewGroup
|
||||
): RecyclerView.ViewHolder {
|
||||
private fun createFooterViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
|
||||
val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,16 +39,27 @@ class BlocksAdapter(
|
|||
) {
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemBlockedUserBinding> {
|
||||
val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val binding = ItemBlockedUserBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemBlockedUserBinding>, position: Int) {
|
||||
override fun onBindAccountViewHolder(
|
||||
viewHolder: BindingHolder<ItemBlockedUserBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val account = accountList[position]
|
||||
val binding = viewHolder.binding
|
||||
val context = binding.root.context
|
||||
|
||||
val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis)
|
||||
val emojifiedName = account.name.emojify(
|
||||
account.emojis,
|
||||
binding.blockedUserDisplayName,
|
||||
animateEmojis
|
||||
)
|
||||
binding.blockedUserDisplayName.text = emojifiedName
|
||||
val formattedUsername = context.getString(R.string.post_username_format, account.username)
|
||||
binding.blockedUserUsername.text = formattedUsername
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ class FollowRequestsAdapter(
|
|||
)
|
||||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
showHeader = false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,12 +27,22 @@ class FollowRequestsHeaderAdapter(
|
|||
private val accountLocked: Boolean
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemFollowRequestsHeaderBinding>>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestsHeaderBinding> {
|
||||
val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemFollowRequestsHeaderBinding> {
|
||||
val binding = ItemFollowRequestsHeaderBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
viewHolder: BindingHolder<ItemFollowRequestsHeaderBinding>,
|
||||
position: Int
|
||||
) {
|
||||
viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,18 +42,29 @@ class MutesAdapter(
|
|||
private val mutingNotificationsMap = HashMap<String, Boolean>()
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
|
||||
val binding = ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val binding = ItemMutedUserBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemMutedUserBinding>, position: Int) {
|
||||
override fun onBindAccountViewHolder(
|
||||
viewHolder: BindingHolder<ItemMutedUserBinding>,
|
||||
position: Int
|
||||
) {
|
||||
val account = accountList[position]
|
||||
val binding = viewHolder.binding
|
||||
val context = binding.root.context
|
||||
|
||||
val mutingNotifications = mutingNotificationsMap[account.id]
|
||||
|
||||
val emojifiedName = account.name.emojify(account.emojis, binding.mutedUserDisplayName, animateEmojis)
|
||||
val emojifiedName = account.name.emojify(
|
||||
account.emojis,
|
||||
binding.mutedUserDisplayName,
|
||||
animateEmojis
|
||||
)
|
||||
binding.mutedUserDisplayName.text = emojifiedName
|
||||
|
||||
val formattedUsername = context.getString(R.string.post_username_format, account.username)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.announcements
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.ContextThemeWrapper
|
||||
|
|
@ -29,13 +30,13 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.EmojiSpan
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
interface AnnouncementActionListener : LinkListener {
|
||||
fun openReactionPicker(announcementId: String, target: View)
|
||||
|
|
@ -50,19 +51,35 @@ class AnnouncementAdapter(
|
|||
private val animateEmojis: Boolean = false
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> {
|
||||
val binding = ItemAnnouncementBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAnnouncementBinding> {
|
||||
val binding = ItemAnnouncementBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemAnnouncementBinding>, position: Int) {
|
||||
val item = items[position]
|
||||
|
||||
holder.binding.announcementDate.text = absoluteTimeFormatter.format(item.publishedAt, false)
|
||||
|
||||
val text = holder.binding.text
|
||||
val chips = holder.binding.chipGroup
|
||||
val addReactionChip = holder.binding.addReactionChip
|
||||
|
||||
val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis)
|
||||
val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(
|
||||
item.emojis,
|
||||
text,
|
||||
animateEmojis
|
||||
)
|
||||
|
||||
setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
|
||||
|
||||
|
|
@ -93,14 +110,20 @@ class AnnouncementAdapter(
|
|||
// 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 span = EmojiSpan(WeakReference(this))
|
||||
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)
|
||||
.asDrawable()
|
||||
.load(if (animateEmojis) { reaction.url } else { reaction.staticUrl })
|
||||
.load(
|
||||
if (animateEmojis) {
|
||||
reaction.url
|
||||
} else {
|
||||
reaction.staticUrl
|
||||
}
|
||||
)
|
||||
.into(span.getTarget(animateEmojis))
|
||||
this.text = spanBuilder
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import android.view.View
|
|||
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
|
||||
|
|
@ -44,6 +45,7 @@ import com.keylesspalace.tusky.util.Loading
|
|||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.view.EmojiPicker
|
||||
|
|
@ -52,6 +54,7 @@ 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 kotlinx.coroutines.launch
|
||||
|
||||
class AnnouncementsActivity :
|
||||
BottomSheetActivity(),
|
||||
|
|
@ -110,35 +113,46 @@ class AnnouncementsActivity :
|
|||
|
||||
binding.announcementsList.adapter = adapter
|
||||
|
||||
viewModel.announcements.observe(this) {
|
||||
when (it) {
|
||||
is Success -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
if (it.data.isNullOrEmpty()) {
|
||||
binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements)
|
||||
binding.errorMessageView.show()
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
viewModel.announcements.collect {
|
||||
if (it == null) return@collect
|
||||
when (it) {
|
||||
is Success -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
if (it.data.isNullOrEmpty()) {
|
||||
binding.errorMessageView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.no_announcements
|
||||
)
|
||||
binding.errorMessageView.show()
|
||||
} else {
|
||||
binding.errorMessageView.hide()
|
||||
}
|
||||
adapter.updateList(it.data ?: listOf())
|
||||
}
|
||||
is Loading -> {
|
||||
binding.errorMessageView.hide()
|
||||
}
|
||||
adapter.updateList(it.data ?: listOf())
|
||||
}
|
||||
is Loading -> {
|
||||
binding.errorMessageView.hide()
|
||||
}
|
||||
is Error -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
refreshAnnouncements()
|
||||
is Error -> {
|
||||
binding.progressBar.hide()
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
binding.errorMessageView.setup(
|
||||
R.drawable.errorphant_error,
|
||||
R.string.error_generic
|
||||
) {
|
||||
refreshAnnouncements()
|
||||
}
|
||||
binding.errorMessageView.show()
|
||||
}
|
||||
binding.errorMessageView.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.emojis.observe(this) {
|
||||
picker.adapter = EmojiAdapter(it, this, animateEmojis)
|
||||
lifecycleScope.launch {
|
||||
viewModel.emoji.collect {
|
||||
picker.adapter = EmojiAdapter(it, this@AnnouncementsActivity, animateEmojis)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.load()
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@
|
|||
package com.keylesspalace.tusky.components.announcements
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
|
|
@ -31,8 +29,11 @@ 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 kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AnnouncementsViewModel @Inject constructor(
|
||||
private val instanceInfoRepo: InstanceInfoRepository,
|
||||
|
|
@ -40,31 +41,33 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
|
||||
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
|
||||
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
|
||||
private val _announcements = MutableStateFlow(null as Resource<List<Announcement>>?)
|
||||
val announcements: StateFlow<Resource<List<Announcement>>?> = _announcements.asStateFlow()
|
||||
|
||||
private val emojisMutable = MutableLiveData<List<Emoji>>()
|
||||
val emojis: LiveData<List<Emoji>> = emojisMutable
|
||||
private val _emoji = MutableStateFlow(emptyList<Emoji>())
|
||||
val emoji: StateFlow<List<Emoji>> = _emoji.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
emojisMutable.postValue(instanceInfoRepo.getEmojis())
|
||||
_emoji.value = instanceInfoRepo.getEmojis()
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
announcementsMutable.postValue(Loading())
|
||||
_announcements.value = Loading()
|
||||
mastodonApi.listAnnouncements()
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(Success(it))
|
||||
_announcements.value = Success(it)
|
||||
it.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.fold(
|
||||
{
|
||||
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
|
||||
eventHub.dispatch(
|
||||
AnnouncementReadEvent(announcement.id)
|
||||
)
|
||||
},
|
||||
{ throwable ->
|
||||
Log.d(
|
||||
|
|
@ -77,7 +80,7 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
}
|
||||
},
|
||||
{
|
||||
announcementsMutable.postValue(Error(cause = it))
|
||||
_announcements.value = Error(cause = it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -88,9 +91,9 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
mastodonApi.addAnnouncementReaction(announcementId, name)
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
_announcements.value =
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
announcements.value?.data?.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
|
||||
|
|
@ -107,7 +110,7 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
} else {
|
||||
listOf(
|
||||
*announcement.reactions.toTypedArray(),
|
||||
emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run {
|
||||
emoji.value.find { emoji -> emoji.shortcode == name }!!.run {
|
||||
Announcement.Reaction(
|
||||
name,
|
||||
1,
|
||||
|
|
@ -124,7 +127,6 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to add reaction to the announcement.", it)
|
||||
|
|
@ -138,7 +140,7 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
mastodonApi.removeAnnouncementReaction(announcementId, name)
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
_announcements.value =
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
|
|
@ -163,7 +165,6 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.app.ProgressDialog
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
|
|
@ -26,6 +25,7 @@ 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
|
||||
import android.os.Bundle
|
||||
|
|
@ -70,6 +70,7 @@ import com.canhub.cropper.CropImage
|
|||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
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.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
|
@ -94,8 +95,9 @@ import com.keylesspalace.tusky.entity.Attachment
|
|||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.settings.AppTheme
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
|
||||
import com.keylesspalace.tusky.util.MentionSpan
|
||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.getInitialLanguages
|
||||
|
|
@ -114,11 +116,6 @@ import com.mikepenz.iconics.IconicsDrawable
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.DecimalFormat
|
||||
|
|
@ -126,6 +123,11 @@ import java.util.Locale
|
|||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class ComposeActivity :
|
||||
BaseActivity(),
|
||||
|
|
@ -162,14 +164,23 @@ class ComposeActivity :
|
|||
|
||||
private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
|
||||
|
||||
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
private val takePicture =
|
||||
registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
|
||||
if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
|
||||
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(
|
||||
this,
|
||||
resources.getQuantityString(
|
||||
R.plurals.error_upload_max_media_reached,
|
||||
maxUploadMediaNumber,
|
||||
maxUploadMediaNumber
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
uris.forEach { uri ->
|
||||
pickMedia(uri)
|
||||
|
|
@ -190,7 +201,8 @@ class ComposeActivity :
|
|||
uriNew,
|
||||
size,
|
||||
itemOld.description,
|
||||
null, // Intentionally reset focus when cropping
|
||||
// Intentionally reset focus when cropping
|
||||
null,
|
||||
itemOld
|
||||
)
|
||||
}
|
||||
|
|
@ -204,27 +216,30 @@ class ComposeActivity :
|
|||
viewModel.cropImageItemOld = null
|
||||
}
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
|
||||
) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
handleCloseButton()
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
|
||||
if (notificationId != -1) {
|
||||
// ComposeActivity was opened from a notification, delete the notification
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
// If started from an intent then compose as the account ID from the intent.
|
||||
// Otherwise use the active account. If null then the user is not logged in,
|
||||
// and return from the activity.
|
||||
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
|
||||
activeAccount = if (intentAccountId != -1L) {
|
||||
accountManager.getAccountById(intentAccountId)
|
||||
} else {
|
||||
accountManager.activeAccount
|
||||
} ?: return
|
||||
|
||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value)
|
||||
if (theme == "black") {
|
||||
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
||||
}
|
||||
|
|
@ -236,7 +251,11 @@ class ComposeActivity :
|
|||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
|
||||
CaptionDialog.newInstance(
|
||||
item.localId,
|
||||
item.description,
|
||||
item.uri
|
||||
).show(supportFragmentManager, "caption_dialog")
|
||||
},
|
||||
onAddFocus = { item ->
|
||||
makeFocusDialog(item.focus, item.uri) { newFocus ->
|
||||
|
|
@ -254,7 +273,11 @@ 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? = IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
COMPOSE_OPTIONS_EXTRA,
|
||||
ComposeOptions::class.java
|
||||
)
|
||||
viewModel.setup(composeOptions)
|
||||
|
||||
setupButtons()
|
||||
|
|
@ -280,7 +303,7 @@ class ComposeActivity :
|
|||
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
|
||||
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
|
||||
setupComposeField(preferences, viewModel.startingText)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
|
|
@ -317,12 +340,20 @@ 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 ->
|
||||
IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
Intent.EXTRA_STREAM,
|
||||
Uri::class.java
|
||||
)?.let { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
|
||||
IntentCompat.getParcelableArrayListExtra(
|
||||
intent,
|
||||
Intent.EXTRA_STREAM,
|
||||
Uri::class.java
|
||||
)?.forEach { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
|
|
@ -342,7 +373,13 @@ class ComposeActivity :
|
|||
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
|
||||
val left = min(start, end)
|
||||
val right = max(start, end)
|
||||
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
|
||||
binding.composeEditField.text.replace(
|
||||
left,
|
||||
right,
|
||||
shareBody,
|
||||
0,
|
||||
shareBody.length
|
||||
)
|
||||
// move edittext cursor to first when shareBody parsed
|
||||
binding.composeEditField.text.insert(0, "\n")
|
||||
binding.composeEditField.setSelection(0)
|
||||
|
|
@ -355,23 +392,48 @@ class ComposeActivity :
|
|||
if (replyingStatusAuthor != null) {
|
||||
binding.composeReplyView.show()
|
||||
binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
||||
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 }
|
||||
val arrowDownIcon = IconicsDrawable(
|
||||
this,
|
||||
GoogleMaterial.Icon.gmd_arrow_drop_down
|
||||
).apply {
|
||||
sizeDp = 12
|
||||
}
|
||||
|
||||
setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
null,
|
||||
null,
|
||||
arrowDownIcon,
|
||||
null
|
||||
)
|
||||
|
||||
binding.composeReplyView.setOnClickListener {
|
||||
TransitionManager.beginDelayedTransition(binding.composeReplyContentView.parent as ViewGroup)
|
||||
TransitionManager.beginDelayedTransition(
|
||||
binding.composeReplyContentView.parent as ViewGroup
|
||||
)
|
||||
|
||||
if (binding.composeReplyContentView.isVisible) {
|
||||
binding.composeReplyContentView.hide()
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
null,
|
||||
null,
|
||||
arrowDownIcon,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
binding.composeReplyContentView.show()
|
||||
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 }
|
||||
val arrowUpIcon = IconicsDrawable(
|
||||
this,
|
||||
GoogleMaterial.Icon.gmd_arrow_drop_up
|
||||
).apply { sizeDp = 12 }
|
||||
|
||||
setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
null,
|
||||
null,
|
||||
arrowUpIcon,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -382,13 +444,21 @@ class ComposeActivity :
|
|||
if (startingContentWarning != null) {
|
||||
binding.composeContentWarningField.setText(startingContentWarning)
|
||||
}
|
||||
binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||
binding.composeContentWarningField.doOnTextChanged { newContentWarning, _, _, _ ->
|
||||
updateVisibleCharactersLeft()
|
||||
viewModel.updateContentWarning(newContentWarning?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
|
||||
binding.composeEditField.setOnReceiveContentListener(this)
|
||||
|
||||
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
|
||||
binding.composeEditField.setOnKeyListener { _, keyCode, event ->
|
||||
this.onKeyDown(
|
||||
keyCode,
|
||||
event
|
||||
)
|
||||
}
|
||||
|
||||
binding.composeEditField.setAdapter(
|
||||
ComposeAutoCompleteAdapter(
|
||||
|
|
@ -408,6 +478,7 @@ class ComposeActivity :
|
|||
binding.composeEditField.doAfterTextChanged { editable ->
|
||||
highlightSpans(editable!!, mentionColour)
|
||||
updateVisibleCharactersLeft()
|
||||
viewModel.updateContent(editable.toString())
|
||||
}
|
||||
|
||||
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
|
||||
|
|
@ -433,7 +504,9 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive ->
|
||||
viewModel.showContentWarning.combine(
|
||||
viewModel.markMediaAsSensitive
|
||||
) { showContentWarning, markSensitive ->
|
||||
updateSensitiveMediaToggle(markSensitive, showContentWarning)
|
||||
showContentWarning(showContentWarning)
|
||||
}.collect()
|
||||
|
|
@ -448,7 +521,10 @@ class ComposeActivity :
|
|||
mediaAdapter.submitList(media)
|
||||
|
||||
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
|
||||
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value)
|
||||
updateSensitiveMediaToggle(
|
||||
viewModel.markMediaAsSensitive.value,
|
||||
viewModel.showContentWarning.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -485,10 +561,21 @@ class ComposeActivity :
|
|||
if (throwable is UploadServerError) {
|
||||
displayTransientMessage(throwable.errorMessage)
|
||||
} else {
|
||||
displayTransientMessage(R.string.error_media_upload_sending)
|
||||
displayTransientMessage(
|
||||
getString(
|
||||
R.string.error_media_upload_sending_fmt,
|
||||
throwable.message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.closeConfirmation.collect {
|
||||
updateOnBackPressedCallbackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
|
|
@ -499,6 +586,17 @@ class ComposeActivity :
|
|||
scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView)
|
||||
emojiBehavior = BottomSheetBehavior.from(binding.emojiView)
|
||||
|
||||
val bottomSheetCallback = object : BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
updateOnBackPressedCallbackState()
|
||||
}
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
composeOptionsBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
addMediaBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
scheduleBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
emojiBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
|
||||
enableButton(binding.composeEmojiButton, clickable = false, colorActive = false)
|
||||
|
||||
// Setup the interface buttons.
|
||||
|
|
@ -519,46 +617,58 @@ class ComposeActivity :
|
|||
|
||||
val textColor = MaterialColors.getColor(binding.root, android.R.attr.textColorTertiary)
|
||||
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply {
|
||||
colorInt = textColor
|
||||
sizeDp = 18
|
||||
}
|
||||
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
cameraIcon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply {
|
||||
colorInt = textColor
|
||||
sizeDp = 18
|
||||
}
|
||||
binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
imageIcon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
|
||||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply {
|
||||
colorInt = textColor
|
||||
sizeDp = 18
|
||||
}
|
||||
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
pollIcon,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
binding.actionPhotoTake.visible(Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null)
|
||||
binding.actionPhotoTake.visible(
|
||||
Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null
|
||||
)
|
||||
|
||||
binding.actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
||||
binding.actionPhotoPick.setOnClickListener { onMediaPick() }
|
||||
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
|
||||
) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
handleCloseButton()
|
||||
}
|
||||
}
|
||||
)
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
|
||||
}
|
||||
|
||||
private fun setupLanguageSpinner(initialLanguages: List<String>) {
|
||||
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long
|
||||
) {
|
||||
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
|
||||
}
|
||||
|
||||
|
|
@ -588,7 +698,7 @@ class ComposeActivity :
|
|||
a.getDimensionPixelSize(0, 1)
|
||||
}
|
||||
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
loadAvatar(
|
||||
activeAccount.profilePictureUrl,
|
||||
binding.composeAvatar,
|
||||
|
|
@ -601,10 +711,23 @@ class ComposeActivity :
|
|||
)
|
||||
}
|
||||
|
||||
private fun updateOnBackPressedCallbackState() {
|
||||
val confirmation = viewModel.closeConfirmation.value
|
||||
onBackPressedCallback.isEnabled = confirmation != ConfirmationKind.NONE ||
|
||||
composeOptionsBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
|
||||
addMediaBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
|
||||
emojiBehavior.state != BottomSheetBehavior.STATE_HIDDEN ||
|
||||
scheduleBehavior.state != BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
private fun replaceTextAtCaret(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val textToInsert = if (start > 0 && !binding.composeEditField.text[start - 1].isWhitespace()) {
|
||||
" $text"
|
||||
} else {
|
||||
|
|
@ -618,8 +741,12 @@ class ComposeActivity :
|
|||
|
||||
fun prependSelectedWordsWith(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd)
|
||||
val start = binding.composeEditField.selectionStart.coerceAtMost(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val end = binding.composeEditField.selectionStart.coerceAtLeast(
|
||||
binding.composeEditField.selectionEnd
|
||||
)
|
||||
val editorText = binding.composeEditField.text
|
||||
|
||||
if (start == end) {
|
||||
|
|
@ -687,7 +814,10 @@ class ComposeActivity :
|
|||
this.viewModel.toggleMarkSensitive()
|
||||
}
|
||||
|
||||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
private fun updateSensitiveMediaToggle(
|
||||
markMediaSensitive: Boolean,
|
||||
contentWarningShown: Boolean
|
||||
) {
|
||||
if (viewModel.media.value.isEmpty()) {
|
||||
binding.composeHideMediaButton.hide()
|
||||
binding.descriptionMissingWarningButton.hide()
|
||||
|
|
@ -704,7 +834,10 @@ class ComposeActivity :
|
|||
getColor(R.color.chinwag_green)
|
||||
} else {
|
||||
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
|
||||
MaterialColors.getColor(binding.composeHideMediaButton, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeHideMediaButton,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
|
|
@ -726,7 +859,10 @@ class ComposeActivity :
|
|||
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
|
||||
} else {
|
||||
@ColorInt val color = if (binding.composeScheduleView.time == null) {
|
||||
MaterialColors.getColor(binding.composeScheduleButton, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeScheduleButton,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
} else {
|
||||
getColor(R.color.chinwag_green)
|
||||
}
|
||||
|
|
@ -757,7 +893,11 @@ class ComposeActivity :
|
|||
binding.composeToggleVisibilityButton.setImageResource(iconRes)
|
||||
if (viewModel.editing) {
|
||||
// Can't update visibility on published status
|
||||
enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false)
|
||||
enableButton(
|
||||
binding.composeToggleVisibilityButton,
|
||||
clickable = false,
|
||||
colorActive = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,7 +934,11 @@ class ComposeActivity :
|
|||
private fun showEmojis() {
|
||||
binding.emojiView.adapter?.let {
|
||||
if (it.itemCount == 0) {
|
||||
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
|
||||
val errorMessage =
|
||||
getString(
|
||||
R.string.error_no_custom_emojis,
|
||||
accountManager.activeAccount!!.domain
|
||||
)
|
||||
displayTransientMessage(errorMessage)
|
||||
} else {
|
||||
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
|
|
@ -822,7 +966,7 @@ class ComposeActivity :
|
|||
|
||||
private fun onMediaPick() {
|
||||
addMediaBehavior.addBottomSheetCallback(
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
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) {
|
||||
|
|
@ -861,9 +1005,14 @@ class ComposeActivity :
|
|||
|
||||
private fun setupPollView() {
|
||||
val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
||||
val marginBottom = resources.getDimensionPixelSize(
|
||||
R.dimen.compose_media_preview_margin_bottom
|
||||
)
|
||||
|
||||
val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
val layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
layoutParams.setMargins(margin, margin, margin, marginBottom)
|
||||
binding.pollPreview.layoutParams = layoutParams
|
||||
|
||||
|
|
@ -885,13 +1034,13 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun removePoll() {
|
||||
viewModel.poll.value = null
|
||||
viewModel.updatePoll(null)
|
||||
binding.pollPreview.hide()
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(visibility: Status.Visibility) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
viewModel.statusVisibility.value = visibility
|
||||
viewModel.changeStatusVisibility(visibility)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
|
@ -914,7 +1063,10 @@ class ComposeActivity :
|
|||
val textColor = if (remainingLength < 0) {
|
||||
getColor(R.color.tusky_red)
|
||||
} else {
|
||||
MaterialColors.getColor(binding.composeCharactersLeftView, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeCharactersLeftView,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
}
|
||||
binding.composeCharactersLeftView.setTextColor(textColor)
|
||||
}
|
||||
|
|
@ -926,7 +1078,9 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun verifyScheduledTime(): Boolean {
|
||||
return binding.composeScheduleView.verifyScheduledTime(binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value))
|
||||
return binding.composeScheduleView.verifyScheduledTime(
|
||||
binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onSendClicked() {
|
||||
|
|
@ -976,7 +1130,11 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
|
||||
|
|
@ -1051,14 +1209,20 @@ class ComposeActivity :
|
|||
val tempFile = createNewImageFile(this, if (isPng) ".png" else ".jpg")
|
||||
|
||||
// "Authority" must be the same as the android:authorities string in AndroidManifest.xml
|
||||
val uriNew = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile)
|
||||
val uriNew = FileProvider.getUriForFile(
|
||||
this,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
tempFile
|
||||
)
|
||||
|
||||
viewModel.cropImageItemOld = item
|
||||
|
||||
cropImage.launch(
|
||||
options(uri = item.uri) {
|
||||
setOutputUri(uriNew)
|
||||
setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
|
||||
setOutputCompressFormat(
|
||||
if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1067,9 +1231,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, description).onFailure { throwable ->
|
||||
viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable ->
|
||||
val errorString = when (throwable) {
|
||||
is FileSizeException -> {
|
||||
val decimalFormat = DecimalFormat("0.##")
|
||||
|
|
@ -1077,7 +1260,9 @@ class ComposeActivity :
|
|||
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 VideoOrImageException -> getString(
|
||||
R.string.error_media_upload_image_or_video
|
||||
)
|
||||
else -> getString(R.string.error_media_upload_opening)
|
||||
}
|
||||
displayTransientMessage(errorString)
|
||||
|
|
@ -1086,16 +1271,23 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun showContentWarning(show: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup)
|
||||
TransitionManager.beginDelayedTransition(
|
||||
binding.composeContentWarningBar.parent as ViewGroup
|
||||
)
|
||||
@ColorInt val color = if (show) {
|
||||
binding.composeContentWarningBar.show()
|
||||
binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length)
|
||||
binding.composeContentWarningField.setSelection(
|
||||
binding.composeContentWarningField.text.length
|
||||
)
|
||||
binding.composeContentWarningField.requestFocus()
|
||||
getColor(R.color.chinwag_green)
|
||||
} else {
|
||||
binding.composeContentWarningBar.hide()
|
||||
binding.composeEditField.requestFocus()
|
||||
MaterialColors.getColor(binding.composeContentWarningButton, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(
|
||||
binding.composeContentWarningButton,
|
||||
android.R.attr.textColorTertiary
|
||||
)
|
||||
}
|
||||
binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
|
@ -1130,10 +1322,10 @@ class ComposeActivity :
|
|||
private fun handleCloseButton() {
|
||||
val contentText = binding.composeEditField.text.toString()
|
||||
val contentWarning = binding.composeContentWarningField.text.toString()
|
||||
when (viewModel.handleCloseButton(contentText, contentWarning)) {
|
||||
when (viewModel.closeConfirmation.value) {
|
||||
ConfirmationKind.NONE -> {
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
ConfirmationKind.SAVE_OR_DISCARD ->
|
||||
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||
|
|
@ -1149,7 +1341,10 @@ class ComposeActivity :
|
|||
/**
|
||||
* User is editing a new post, and can either save the changes as a draft or discard them.
|
||||
*/
|
||||
private fun getSaveAsDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
private fun getSaveAsDraftOrDiscardDialog(
|
||||
contentText: String,
|
||||
contentWarning: String
|
||||
): AlertDialog.Builder {
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
|
|
@ -1172,7 +1367,10 @@ class ComposeActivity :
|
|||
* User is editing an existing draft, and can either update the draft with the new changes or
|
||||
* discard them.
|
||||
*/
|
||||
private fun getUpdateDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
private fun getUpdateDraftOrDiscardDialog(
|
||||
contentText: String,
|
||||
contentWarning: String
|
||||
): AlertDialog.Builder {
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
|
|
@ -1187,7 +1385,7 @@ class ComposeActivity :
|
|||
}
|
||||
.setNegativeButton(R.string.action_discard) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1203,7 +1401,7 @@ class ComposeActivity :
|
|||
}
|
||||
.setNegativeButton(R.string.action_discard) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1217,7 +1415,7 @@ class ComposeActivity :
|
|||
.setPositiveButton(R.string.action_delete) { _, _ ->
|
||||
viewModel.deleteDraft()
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
|
||||
// Do nothing, dialog will dismiss, user can continue editing
|
||||
|
|
@ -1226,7 +1424,7 @@ class ComposeActivity :
|
|||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
|
||||
|
|
@ -1244,7 +1442,7 @@ class ComposeActivity :
|
|||
}
|
||||
viewModel.saveDraft(contentText, contentWarning)
|
||||
dialog?.cancel()
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1276,10 +1474,15 @@ class ComposeActivity :
|
|||
val state: State
|
||||
) {
|
||||
enum class Type {
|
||||
IMAGE, VIDEO, AUDIO;
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
AUDIO
|
||||
}
|
||||
enum class State {
|
||||
UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED
|
||||
UPLOADING,
|
||||
UNPROCESSED,
|
||||
PROCESSED,
|
||||
PUBLISHED
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1350,8 +1553,6 @@ class ComposeActivity :
|
|||
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
|
||||
|
||||
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
|
||||
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
|
||||
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
|
||||
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
|
||||
private const val VISIBILITY_KEY = "VISIBILITY"
|
||||
private const val SCHEDULED_TIME_KEY = "SCHEDULE"
|
||||
|
|
@ -1359,26 +1560,12 @@ class ComposeActivity :
|
|||
|
||||
/**
|
||||
* @param options ComposeOptions to configure the ComposeActivity
|
||||
* @param notificationId the id of the notification that starts the Activity
|
||||
* @param accountId the id of the account to compose with, null for the current account
|
||||
* @return an Intent to start the ComposeActivity
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun startIntent(
|
||||
context: Context,
|
||||
options: ComposeOptions,
|
||||
notificationId: Int? = null,
|
||||
accountId: Long? = null
|
||||
): Intent {
|
||||
fun startIntent(context: Context, options: ComposeOptions): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(COMPOSE_OPTIONS_EXTRA, options)
|
||||
if (notificationId != null) {
|
||||
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
|
||||
}
|
||||
if (accountId != null) {
|
||||
putExtra(ACCOUNT_ID_EXTRA, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1410,7 +1597,7 @@ class ComposeActivity :
|
|||
*/
|
||||
@JvmStatic
|
||||
fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int {
|
||||
var length = body.length - body.getSpans(0, body.length, URLSpan::class.java)
|
||||
var length = body.toString().perceivedCharacterLength() - body.getSpans(0, body.length, URLSpan::class.java)
|
||||
.fold(0) { acc, span ->
|
||||
// Accumulate a count of characters to be *ignored* in the final length
|
||||
acc + when (span) {
|
||||
|
|
@ -1423,15 +1610,25 @@ class ComposeActivity :
|
|||
}
|
||||
else -> {
|
||||
// Expected to be negative if the URL length < maxUrlLength
|
||||
span.url.length - urlLength
|
||||
span.url.perceivedCharacterLength() - urlLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content warning text is treated as is, URLs or mentions there are not special
|
||||
contentWarning?.let { length += it.length }
|
||||
|
||||
contentWarning?.let { length += it.toString().perceivedCharacterLength() }
|
||||
return length
|
||||
}
|
||||
|
||||
// String.length would count emojis as multiple characters but Mastodon counts them as 1, so we need this workaround
|
||||
private fun String.perceivedCharacterLength(): Int {
|
||||
val breakIterator = BreakIterator.getCharacterInstance()
|
||||
breakIterator.setText(this)
|
||||
var count = 0
|
||||
while (breakIterator.next() != BreakIterator.DONE) {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,9 @@ class ComposeAutoCompleteAdapter(
|
|||
val account = accountResult.account
|
||||
binding.username.text = context.getString(R.string.post_username_format, account.username)
|
||||
binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_42dp
|
||||
)
|
||||
loadAvatar(
|
||||
account.avatar,
|
||||
binding.avatar,
|
||||
|
|
@ -143,12 +145,12 @@ class ComposeAutoCompleteAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
sealed class AutocompleteResult {
|
||||
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
|
||||
sealed interface AutocompleteResult {
|
||||
class AccountResult(val account: TimelineAccount) : AutocompleteResult
|
||||
|
||||
class HashtagResult(val hashtag: String) : AutocompleteResult()
|
||||
class HashtagResult(val hashtag: String) : AutocompleteResult
|
||||
|
||||
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
|
||||
class EmojiResult(val emoji: Emoji) : AutocompleteResult
|
||||
}
|
||||
|
||||
interface AutocompletionProvider {
|
||||
|
|
|
|||
|
|
@ -38,22 +38,24 @@ 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 javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class ComposeViewModel @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
|
|
@ -78,34 +80,61 @@ class ComposeViewModel @Inject constructor(
|
|||
private var modifiedInitialState: Boolean = false
|
||||
private var hasScheduledTimeChanged: Boolean = false
|
||||
|
||||
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
|
||||
private var currentContent: String? = ""
|
||||
private var currentContentWarning: String? = ""
|
||||
|
||||
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
val markMediaAsSensitive: MutableStateFlow<Boolean> =
|
||||
private val _markMediaAsSensitive =
|
||||
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
val markMediaAsSensitive: StateFlow<Boolean> = _markMediaAsSensitive.asStateFlow()
|
||||
|
||||
val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
|
||||
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
|
||||
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN)
|
||||
val statusVisibility: StateFlow<Status.Visibility> = _statusVisibility.asStateFlow()
|
||||
|
||||
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val _showContentWarning = MutableStateFlow(false)
|
||||
val showContentWarning: StateFlow<Boolean> = _showContentWarning.asStateFlow()
|
||||
|
||||
lateinit var composeKind: ComposeKind
|
||||
private val _poll = MutableStateFlow(null as NewPoll?)
|
||||
val poll: StateFlow<NewPoll?> = _poll.asStateFlow()
|
||||
|
||||
private val _scheduledAt = MutableStateFlow(null as String?)
|
||||
val scheduledAt: StateFlow<String?> = _scheduledAt.asStateFlow()
|
||||
|
||||
private val _media = MutableStateFlow(emptyList<QueuedMedia>())
|
||||
val media: StateFlow<List<QueuedMedia>> = _media.asStateFlow()
|
||||
|
||||
private val _uploadError = MutableSharedFlow<Throwable>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val uploadError: SharedFlow<Throwable> = _uploadError.asSharedFlow()
|
||||
|
||||
private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
|
||||
val closeConfirmation: StateFlow<ConfirmationKind> = _closeConfirmation.asStateFlow()
|
||||
|
||||
private lateinit var composeKind: ComposeKind
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
||||
private var setupComplete = false
|
||||
|
||||
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
|
||||
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
|
||||
val mediaItems = _media.value
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
mediaItems[0].type == QueuedMedia.Type.IMAGE
|
||||
|
|
@ -130,7 +159,7 @@ class ComposeViewModel @Inject constructor(
|
|||
): QueuedMedia {
|
||||
var stashMediaItem: QueuedMedia? = null
|
||||
|
||||
media.update { mediaList ->
|
||||
_media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
|
|
@ -157,7 +186,7 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaUploader
|
||||
.uploadMedia(mediaItem, instanceInfo.first())
|
||||
.collect { event ->
|
||||
val item = media.value.find { it.localId == mediaItem.localId }
|
||||
val item = _media.value.find { it.localId == mediaItem.localId }
|
||||
?: return@collect
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
|
|
@ -166,15 +195,19 @@ class ComposeViewModel @Inject constructor(
|
|||
item.copy(
|
||||
id = event.mediaId,
|
||||
uploadPercent = -1,
|
||||
state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED }
|
||||
state = if (event.processed) {
|
||||
QueuedMedia.State.PROCESSED
|
||||
} else {
|
||||
QueuedMedia.State.UNPROCESSED
|
||||
}
|
||||
)
|
||||
is UploadEvent.ErrorEvent -> {
|
||||
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||
uploadError.emit(event.error)
|
||||
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||
_uploadError.emit(event.error)
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
media.update { mediaList ->
|
||||
_media.update { mediaList ->
|
||||
mediaList.map { mediaItem ->
|
||||
if (mediaItem.localId == newMediaItem.localId) {
|
||||
newMediaItem
|
||||
|
|
@ -185,11 +218,22 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
updateCloseConfirmation()
|
||||
return mediaItem
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
||||
media.update { mediaList ->
|
||||
fun changeStatusVisibility(visibility: Status.Visibility) {
|
||||
_statusVisibility.value = visibility
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(
|
||||
id: String,
|
||||
type: QueuedMedia.Type,
|
||||
uri: Uri,
|
||||
description: String?,
|
||||
focus: Attachment.Focus?
|
||||
) {
|
||||
_media.update { mediaList ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
|
|
@ -207,22 +251,38 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaUploader.cancelUploadScope(item.localId)
|
||||
media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
|
||||
_media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun toggleMarkSensitive() {
|
||||
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
|
||||
this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true
|
||||
}
|
||||
|
||||
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
|
||||
return if (didChange(contentText, contentWarning)) {
|
||||
fun updateContent(newContent: String?) {
|
||||
currentContent = newContent
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun updateContentWarning(newContentWarning: String?) {
|
||||
currentContentWarning = newContentWarning
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
private fun updateCloseConfirmation() {
|
||||
val contentWarning = if (_showContentWarning.value) {
|
||||
currentContentWarning
|
||||
} else {
|
||||
""
|
||||
}
|
||||
this._closeConfirmation.value = if (didChange(currentContent, contentWarning)) {
|
||||
when (composeKind) {
|
||||
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
|
||||
ComposeKind.NEW -> if (isEmpty(currentContent, contentWarning)) {
|
||||
ConfirmationKind.NONE
|
||||
} else {
|
||||
ConfirmationKind.SAVE_OR_DISCARD
|
||||
}
|
||||
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
|
||||
ComposeKind.EDIT_DRAFT -> if (isEmpty(currentContent, contentWarning)) {
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
|
||||
} else {
|
||||
ConfirmationKind.UPDATE_OR_DISCARD
|
||||
|
|
@ -238,20 +298,21 @@ class ComposeViewModel @Inject constructor(
|
|||
private fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
val textChanged = content.orEmpty() != startingText.orEmpty()
|
||||
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
val mediaChanged = _media.value.isNotEmpty()
|
||||
val pollChanged = _poll.value != null
|
||||
val didScheduledTimeChange = hasScheduledTimeChanged
|
||||
|
||||
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
|
||||
}
|
||||
|
||||
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
|
||||
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
|
||||
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && _media.value.isEmpty() && _poll.value == null)
|
||||
}
|
||||
|
||||
fun contentWarningChanged(value: Boolean) {
|
||||
showContentWarning.value = value
|
||||
_showContentWarning.value = value
|
||||
contentWarningStateChanged = true
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun deleteDraft() {
|
||||
|
|
@ -263,12 +324,12 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun stopUploads() {
|
||||
mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray())
|
||||
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 ->
|
||||
return _media.value.any { mediaValue ->
|
||||
mediaValue.uri.scheme == "https"
|
||||
}
|
||||
}
|
||||
|
|
@ -277,7 +338,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val mediaUris: MutableList<String> = mutableListOf()
|
||||
val mediaDescriptions: MutableList<String?> = mutableListOf()
|
||||
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
|
||||
media.value.forEach { item ->
|
||||
for (item in _media.value) {
|
||||
mediaUris.add(item.uri.toString())
|
||||
mediaDescriptions.add(item.description)
|
||||
mediaFocus.add(item.focus)
|
||||
|
|
@ -289,15 +350,15 @@ class ComposeViewModel @Inject constructor(
|
|||
inReplyToId = inReplyToId,
|
||||
content = content,
|
||||
contentWarning = contentWarning,
|
||||
sensitive = markMediaAsSensitive.value,
|
||||
visibility = statusVisibility.value,
|
||||
sensitive = _markMediaAsSensitive.value,
|
||||
visibility = _statusVisibility.value,
|
||||
mediaUris = mediaUris,
|
||||
mediaDescriptions = mediaDescriptions,
|
||||
mediaFocus = mediaFocus,
|
||||
poll = poll.value,
|
||||
poll = _poll.value,
|
||||
failedToSend = false,
|
||||
failedToSendAlert = false,
|
||||
scheduledAt = scheduledAt.value,
|
||||
scheduledAt = _scheduledAt.value,
|
||||
language = postLanguage,
|
||||
statusId = originalStatusId
|
||||
)
|
||||
|
|
@ -307,16 +368,12 @@ class ComposeViewModel @Inject constructor(
|
|||
* Send status to the server.
|
||||
* Uses current state plus provided arguments.
|
||||
*/
|
||||
suspend fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String,
|
||||
accountId: Long
|
||||
) {
|
||||
suspend fun sendStatus(content: String, spoilerText: String, accountId: Long) {
|
||||
if (!scheduledTootId.isNullOrEmpty()) {
|
||||
api.deleteScheduledStatus(scheduledTootId!!)
|
||||
}
|
||||
|
||||
val attachedMedia = media.value.map { item ->
|
||||
val attachedMedia = _media.value.map { item ->
|
||||
MediaToSend(
|
||||
localId = item.localId,
|
||||
id = item.id,
|
||||
|
|
@ -329,12 +386,12 @@ class ComposeViewModel @Inject constructor(
|
|||
val tootToSend = StatusToSend(
|
||||
text = content,
|
||||
warningText = spoilerText,
|
||||
visibility = statusVisibility.value.serverString(),
|
||||
sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
|
||||
visibility = _statusVisibility.value.serverString,
|
||||
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value),
|
||||
media = attachedMedia,
|
||||
scheduledAt = scheduledAt.value,
|
||||
scheduledAt = _scheduledAt.value,
|
||||
inReplyToId = inReplyToId,
|
||||
poll = poll.value,
|
||||
poll = _poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
accountId = accountId,
|
||||
|
|
@ -349,7 +406,7 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
|
||||
media.update { mediaList ->
|
||||
_media.update { mediaList ->
|
||||
mediaList.map { mediaItem ->
|
||||
if (mediaItem.localId == localId) {
|
||||
mutator(mediaItem)
|
||||
|
|
@ -373,9 +430,9 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return api.searchAccountsSync(query = token.substring(1), limit = 10)
|
||||
return when (token[0]) {
|
||||
'@' -> runBlocking {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.fold({ accounts ->
|
||||
accounts.map { AutocompleteResult.AccountResult(it) }
|
||||
}, { e ->
|
||||
|
|
@ -383,8 +440,12 @@ class ComposeViewModel @Inject constructor(
|
|||
emptyList()
|
||||
})
|
||||
}
|
||||
'#' -> {
|
||||
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
'#' -> runBlocking {
|
||||
api.search(
|
||||
query = token,
|
||||
type = SearchType.Hashtag.apiParameter,
|
||||
limit = 10
|
||||
)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
|
||||
}, { e ->
|
||||
|
|
@ -396,7 +457,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
|
||||
val incomplete = token.substring(1)
|
||||
|
||||
return emojiList.filter { emoji ->
|
||||
emojiList.filter { emoji ->
|
||||
emoji.shortcode.contains(incomplete, ignoreCase = true)
|
||||
}.sortedBy { emoji ->
|
||||
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
|
||||
|
|
@ -406,7 +467,7 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
return emptyList()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -434,7 +495,7 @@ class ComposeViewModel @Inject constructor(
|
|||
startingContentWarning = contentWarning
|
||||
}
|
||||
if (!contentWarningStateChanged) {
|
||||
showContentWarning.value = !contentWarning.isNullOrBlank()
|
||||
_showContentWarning.value = !contentWarning.isNullOrBlank()
|
||||
}
|
||||
|
||||
// recreate media list
|
||||
|
|
@ -468,7 +529,7 @@ class ComposeViewModel @Inject constructor(
|
|||
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
|
||||
startingVisibility = tootVisibility
|
||||
}
|
||||
statusVisibility.value = startingVisibility
|
||||
_statusVisibility.value = startingVisibility
|
||||
val mentionedUsernames = composeOptions?.mentionedUsernames
|
||||
if (mentionedUsernames != null) {
|
||||
val builder = StringBuilder()
|
||||
|
|
@ -480,30 +541,33 @@ class ComposeViewModel @Inject constructor(
|
|||
startingText = builder.toString()
|
||||
}
|
||||
|
||||
scheduledAt.value = composeOptions?.scheduledAt
|
||||
_scheduledAt.value = composeOptions?.scheduledAt
|
||||
|
||||
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
|
||||
composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it }
|
||||
|
||||
val poll = composeOptions?.poll
|
||||
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
|
||||
this.poll.value = poll
|
||||
this._poll.value = poll
|
||||
}
|
||||
replyingStatusContent = composeOptions?.replyingStatusContent
|
||||
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
|
||||
|
||||
updateCloseConfirmation()
|
||||
|
||||
setupComplete = true
|
||||
}
|
||||
|
||||
fun updatePoll(newPoll: NewPoll) {
|
||||
poll.value = newPoll
|
||||
fun updatePoll(newPoll: NewPoll?) {
|
||||
_poll.value = newPoll
|
||||
updateCloseConfirmation()
|
||||
}
|
||||
|
||||
fun updateScheduledAt(newScheduledAt: String?) {
|
||||
if (newScheduledAt != scheduledAt.value) {
|
||||
if (newScheduledAt != _scheduledAt.value) {
|
||||
hasScheduledTimeChanged = true
|
||||
}
|
||||
|
||||
scheduledAt.value = newScheduledAt
|
||||
_scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
val editing: Boolean
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ fun downsizeImage(
|
|||
tempFile: File
|
||||
): Boolean {
|
||||
val decodeBoundsInputStream = try {
|
||||
contentResolver.openInputStream(uri)
|
||||
contentResolver.openInputStream(uri) ?: return false
|
||||
} catch (e: FileNotFoundException) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -54,10 +54,10 @@ fun downsizeImage(
|
|||
// Get EXIF data, for orientation info.
|
||||
val orientation = getImageOrientation(uri, contentResolver)
|
||||
/* Unfortunately, there isn't a determined worst case compression ratio for image
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
var scaledImageSize = 1024
|
||||
do {
|
||||
val outputStream = try {
|
||||
|
|
@ -66,7 +66,7 @@ fun downsizeImage(
|
|||
return false
|
||||
}
|
||||
val decodeBitmapInputStream = try {
|
||||
contentResolver.openInputStream(uri)
|
||||
contentResolver.openInputStream(uri) ?: return false
|
||||
} catch (e: FileNotFoundException) {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,11 +113,17 @@ class MediaPreviewAdapter(
|
|||
private val differ = AsyncListDiffer(
|
||||
this,
|
||||
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
|
||||
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
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 {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: ComposeActivity.QueuedMedia,
|
||||
newItem: ComposeActivity.QueuedMedia
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,16 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||
import com.keylesspalace.tusky.network.MediaUploadApi
|
||||
import com.keylesspalace.tusky.network.ProgressRequestBody
|
||||
import com.keylesspalace.tusky.network.asRequestBody
|
||||
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
|
||||
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 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
|
||||
|
|
@ -52,21 +56,20 @@ import kotlinx.coroutines.flow.flow
|
|||
import kotlinx.coroutines.flow.shareIn
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
sealed interface FinalUploadEvent
|
||||
|
||||
sealed class UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent()
|
||||
data class FinishedEvent(val mediaId: String, val processed: Boolean) : UploadEvent(), FinalUploadEvent
|
||||
data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent
|
||||
sealed interface UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent
|
||||
data class FinishedEvent(
|
||||
val mediaId: String,
|
||||
val processed: Boolean
|
||||
) : UploadEvent, FinalUploadEvent
|
||||
data class ErrorEvent(val error: Throwable) : UploadEvent, FinalUploadEvent
|
||||
}
|
||||
|
||||
data class UploadData(
|
||||
|
|
@ -79,11 +82,7 @@ fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
|
|||
val randomId = randomAlphanumericString(12)
|
||||
val imageFileName = "Tusky_${randomId}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(
|
||||
imageFileName, /* prefix */
|
||||
suffix, /* suffix */
|
||||
storageDir /* directory */
|
||||
)
|
||||
return File.createTempFile(imageFileName, suffix, storageDir)
|
||||
}
|
||||
|
||||
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
||||
|
|
@ -163,22 +162,22 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
contentResolver.openInputStream(inUri)?.source().use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
file.absoluteFile.sink().buffer().use { out ->
|
||||
out.writeAll(input)
|
||||
}
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
}
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
|
|
@ -191,17 +190,18 @@ class MediaUploader @Inject constructor(
|
|||
val suffix = inputFile.name.substringAfterLast('.', "tmp")
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
|
||||
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
|
||||
val input = FileInputStream(inputFile)
|
||||
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
inputFile.source().use { input ->
|
||||
file.absoluteFile.sink().buffer().use { out ->
|
||||
out.writeAll(input)
|
||||
}
|
||||
}
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown uri scheme $uri")
|
||||
|
|
@ -254,9 +254,9 @@ class MediaUploader @Inject constructor(
|
|||
// .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details.
|
||||
// Sniff the content of the file to determine the actual type.
|
||||
if (mimeType != null && (
|
||||
mimeType.startsWith("audio/", ignoreCase = true) ||
|
||||
mimeType.startsWith("video/", ignoreCase = true)
|
||||
)
|
||||
mimeType.startsWith("audio/", ignoreCase = true) ||
|
||||
mimeType.startsWith("video/", ignoreCase = true)
|
||||
)
|
||||
) {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(context, media.uri)
|
||||
|
|
@ -264,22 +264,20 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
val filename = "%s_%s_%s.%s".format(
|
||||
val filename = "%s_%d_%s.%s".format(
|
||||
context.getString(R.string.app_name),
|
||||
Date().time.toString(),
|
||||
System.currentTimeMillis(),
|
||||
randomAlphanumericString(10),
|
||||
fileExtension
|
||||
)
|
||||
|
||||
val stream = contentResolver.openInputStream(media.uri)
|
||||
|
||||
if (mimeType == null) mimeType = "multipart/form-data"
|
||||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(
|
||||
stream!!,
|
||||
media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()!!
|
||||
val fileBody = media.uri.asRequestBody(
|
||||
contentResolver,
|
||||
requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
|
||||
media.mediaSize
|
||||
) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
trySend(UploadEvent.ProgressEvent(percentage))
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ fun showAddPollDialog(
|
|||
binding.pollChoices.adapter = adapter
|
||||
|
||||
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
|
||||
val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
|
||||
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)
|
||||
}
|
||||
|
|
@ -75,8 +77,8 @@ fun showAddPollDialog(
|
|||
}
|
||||
}
|
||||
|
||||
val DAY_SECONDS = 60 * 60 * 24
|
||||
val desiredDuration = poll?.expiresIn ?: DAY_SECONDS
|
||||
val secondsInADay = 60 * 60 * 24
|
||||
val desiredDuration = poll?.expiresIn ?: secondsInADay
|
||||
val pollDurationId = durations.indexOfLast {
|
||||
it <= desiredDuration
|
||||
}
|
||||
|
|
@ -105,5 +107,7 @@ fun showAddPollDialog(
|
|||
dialog.show()
|
||||
|
||||
// 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)
|
||||
dialog.window?.clearFlags(
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,15 @@ class AddPollOptionsAdapter(
|
|||
notifyItemInserted(options.size - 1)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAddPollOptionBinding> {
|
||||
val binding = ItemAddPollOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemAddPollOptionBinding> {
|
||||
val binding = ItemAddPollOptionBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val holder = BindingHolder(binding)
|
||||
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
|
||||
|
||||
|
|
@ -75,10 +82,6 @@ class AddPollOptionsAdapter(
|
|||
}
|
||||
|
||||
private fun validateInput(): Boolean {
|
||||
if (options.contains("") || options.distinct().size != options.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return !(options.contains("") || options.distinct().size != options.size)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
|
|
@ -25,8 +24,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
|
|
@ -36,45 +34,56 @@ import com.bumptech.glide.request.target.CustomTarget
|
|||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
||||
|
||||
class CaptionDialog : DialogFragment() {
|
||||
private lateinit var listener: Listener
|
||||
private lateinit var input: EditText
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
private val binding by viewBinding(DialogImageDescriptionBinding::bind)
|
||||
|
||||
val binding = DialogImageDescriptionBinding.inflate(layoutInflater)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
|
||||
}
|
||||
|
||||
input = binding.imageDescriptionText
|
||||
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?) {
|
||||
val imageView = binding.imageDescriptionView
|
||||
imageView.maximumScale = 6f
|
||||
imageView.maxZoom = 6f
|
||||
|
||||
input.hint = resources.getQuantityString(
|
||||
binding.imageDescriptionText.hint = resources.getQuantityString(
|
||||
R.plurals.hint_describe_for_visually_impaired,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT
|
||||
)
|
||||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
|
||||
binding.imageDescriptionText.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
binding.imageDescriptionText.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
|
||||
savedInstanceState?.getCharSequence(DESCRIPTION_KEY)?.let {
|
||||
binding.imageDescriptionText.setText(it)
|
||||
}
|
||||
|
||||
binding.cancelButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
listener.onUpdateDescription(localId, input.text.toString())
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
binding.okButton.setOnClickListener {
|
||||
listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString())
|
||||
dismiss()
|
||||
}
|
||||
|
||||
isCancelable = true
|
||||
val window = dialog.window
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
|
||||
|
||||
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
|
|
@ -90,27 +99,30 @@ class CaptionDialog : DialogFragment() {
|
|||
) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
})
|
||||
|
||||
return dialog
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
super.onLoadFailed(errorDrawable)
|
||||
imageView.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.apply {
|
||||
window?.setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putString(DESCRIPTION_KEY, input.text.toString())
|
||||
outState.putCharSequence(DESCRIPTION_KEY, binding.imageDescriptionText.text)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
|
||||
input.setText(it)
|
||||
}
|
||||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener")
|
||||
|
|
@ -121,17 +133,14 @@ class CaptionDialog : DialogFragment() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(
|
||||
localId: Int,
|
||||
existingDescription: String?,
|
||||
previewUri: Uri
|
||||
) = CaptionDialog().apply {
|
||||
arguments = bundleOf(
|
||||
LOCAL_ID_ARG to localId,
|
||||
EXISTING_DESCRIPTION_ARG to existingDescription,
|
||||
PREVIEW_URI_ARG to previewUri
|
||||
)
|
||||
}
|
||||
fun newInstance(localId: Int, existingDescription: String?, previewUri: Uri) =
|
||||
CaptionDialog().apply {
|
||||
arguments = bundleOf(
|
||||
LOCAL_ID_ARG to localId,
|
||||
EXISTING_DESCRIPTION_ARG to existingDescription,
|
||||
PREVIEW_URI_ARG to previewUri
|
||||
)
|
||||
}
|
||||
|
||||
private const val DESCRIPTION_KEY = "description"
|
||||
private const val EXISTING_DESCRIPTION_ARG = "existing_description"
|
||||
|
|
|
|||
|
|
@ -49,12 +49,23 @@ fun <T> T.makeFocusDialog(
|
|||
.load(previewUri)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
|
||||
override fun onLoadFailed(
|
||||
p0: GlideException?,
|
||||
p1: Any?,
|
||||
p2: Target<Drawable?>,
|
||||
p3: Boolean
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
|
||||
val width = resource!!.intrinsicWidth
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
model: Any,
|
||||
target: Target<Drawable?>?,
|
||||
dataSource: DataSource,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
val width = resource.intrinsicWidth
|
||||
val height = resource.intrinsicHeight
|
||||
|
||||
dialogBinding.focusIndicator.setImageSize(width, height)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ import android.widget.RadioGroup
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
||||
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(context, attrs) {
|
||||
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(
|
||||
context,
|
||||
attrs
|
||||
) {
|
||||
|
||||
var listener: ComposeOptionsListener? = null
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ class ComposeScheduleView
|
|||
).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
private var scheduleDateTime: Calendar? = null
|
||||
|
||||
/** The date/time the user has chosen to schedule the status, in UTC */
|
||||
private var scheduleDateTimeUtc: Calendar? = null
|
||||
|
||||
init {
|
||||
binding.scheduledDateTime.setOnClickListener { openPickDateDialog() }
|
||||
|
|
@ -71,13 +73,13 @@ class ComposeScheduleView
|
|||
}
|
||||
|
||||
private fun updateScheduleUi() {
|
||||
if (scheduleDateTime == null) {
|
||||
if (scheduleDateTimeUtc == null) {
|
||||
binding.scheduledDateTime.text = ""
|
||||
binding.invalidScheduleWarning.visibility = GONE
|
||||
return
|
||||
}
|
||||
|
||||
val scheduled = scheduleDateTime!!.time
|
||||
val scheduled = scheduleDateTimeUtc!!.time
|
||||
binding.scheduledDateTime.text = String.format(
|
||||
"%s %s",
|
||||
dateFormat.format(scheduled),
|
||||
|
|
@ -98,21 +100,37 @@ class ComposeScheduleView
|
|||
}
|
||||
|
||||
fun resetSchedule() {
|
||||
scheduleDateTime = null
|
||||
scheduleDateTimeUtc = null
|
||||
updateScheduleUi()
|
||||
}
|
||||
|
||||
fun openPickDateDialog() {
|
||||
val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000
|
||||
// The earliest point in time the calendar should display. Start with current date/time
|
||||
val earliest = calendar().apply {
|
||||
// Add the minimum scheduling interval. This may roll the calendar over to the
|
||||
// next day (e.g. if the current time is 23:57).
|
||||
add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS)
|
||||
// Clear out the time components, so it's midnight
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val calendarConstraints = CalendarConstraints.Builder()
|
||||
.setValidator(
|
||||
DateValidatorPointForward.from(yesterday)
|
||||
)
|
||||
.setValidator(DateValidatorPointForward.from(earliest.timeInMillis))
|
||||
.build()
|
||||
initializeSuggestedTime()
|
||||
|
||||
// Work around a misfeature in MaterialDatePicker. The `selection` is treated as
|
||||
// millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC
|
||||
// instead of converting to the user's local timezone.
|
||||
//
|
||||
// So we have to add the TZ offset before setting it in the picker
|
||||
val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis)
|
||||
|
||||
val picker = MaterialDatePicker.Builder
|
||||
.datePicker()
|
||||
.setSelection(scheduleDateTime!!.timeInMillis)
|
||||
.setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset)
|
||||
.setCalendarConstraints(calendarConstraints)
|
||||
.build()
|
||||
picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) }
|
||||
|
|
@ -129,11 +147,12 @@ class ComposeScheduleView
|
|||
|
||||
private fun openPickTimeDialog() {
|
||||
val pickerBuilder = MaterialTimePicker.Builder()
|
||||
scheduleDateTime?.let {
|
||||
scheduleDateTimeUtc?.let {
|
||||
pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY])
|
||||
.setMinute(it[Calendar.MINUTE])
|
||||
}
|
||||
|
||||
pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis))
|
||||
pickerBuilder.setTimeFormat(getTimeFormat(context))
|
||||
|
||||
val picker = pickerBuilder.build()
|
||||
|
|
@ -154,7 +173,7 @@ class ComposeScheduleView
|
|||
fun setDateTime(scheduledAt: String?) {
|
||||
val date = getDateTime(scheduledAt) ?: return
|
||||
initializeSuggestedTime()
|
||||
scheduleDateTime!!.time = date
|
||||
scheduleDateTimeUtc!!.time = date
|
||||
updateScheduleUi()
|
||||
}
|
||||
|
||||
|
|
@ -180,31 +199,32 @@ class ComposeScheduleView
|
|||
// see https://github.com/material-components/material-components-android/issues/882
|
||||
newDate.timeZone = TimeZone.getTimeZone("UTC")
|
||||
newDate.timeInMillis = selection
|
||||
scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
|
||||
scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
|
||||
openPickTimeDialog()
|
||||
}
|
||||
|
||||
private fun onTimeSet(hourOfDay: Int, minute: Int) {
|
||||
initializeSuggestedTime()
|
||||
scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
scheduleDateTime?.set(Calendar.MINUTE, minute)
|
||||
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
|
||||
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
|
||||
updateScheduleUi()
|
||||
listener?.onTimeSet(time)
|
||||
}
|
||||
|
||||
val time: String?
|
||||
get() = scheduleDateTime?.time?.let { iso8601.format(it) }
|
||||
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
|
||||
|
||||
private fun initializeSuggestedTime() {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduleDateTime = calendar().apply {
|
||||
if (scheduleDateTimeUtc == null) {
|
||||
scheduleDateTimeUtc = calendar().apply {
|
||||
add(Calendar.MINUTE, 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
var MINIMUM_SCHEDULED_SECONDS = 330 // Minimum is 5 minutes, pad 30 seconds for posting
|
||||
// Minimum is 5 minutes, pad 30 seconds for posting
|
||||
private const val MINIMUM_SCHEDULED_SECONDS = 330
|
||||
fun calendar(): Calendar = Calendar.getInstance(TimeZone.getDefault())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ class FocusIndicatorView
|
|||
return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
|
||||
@SuppressLint(
|
||||
"ClickableViewAccessibility"
|
||||
) // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
return false
|
||||
|
|
@ -112,7 +114,13 @@ class FocusIndicatorView
|
|||
|
||||
curtainPath.reset() // Draw a flood fill with a hole cut out of it
|
||||
curtainPath.fillType = Path.FillType.WINDING
|
||||
curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW)
|
||||
curtainPath.addRect(
|
||||
0.0f,
|
||||
0.0f,
|
||||
this.width.toFloat(),
|
||||
this.height.toFloat(),
|
||||
Path.Direction.CW
|
||||
)
|
||||
curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW)
|
||||
canvas.drawPath(curtainPath, curtainPaint)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ class TootButton
|
|||
Status.Visibility.PRIVATE,
|
||||
Status.Visibility.DIRECT -> {
|
||||
setText(R.string.action_send)
|
||||
IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply { sizeDp = 18; colorInt = Color.WHITE }
|
||||
IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply {
|
||||
sizeDp = 18
|
||||
colorInt = Color.WHITE
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ class ConversationAdapter(
|
|||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
||||
val view = LayoutInflater.from(
|
||||
parent.context
|
||||
).inflate(R.layout.item_conversation, parent, false)
|
||||
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||
}
|
||||
|
||||
|
|
@ -58,15 +60,24 @@ class ConversationAdapter(
|
|||
|
||||
companion object {
|
||||
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
|
||||
override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: ConversationViewData,
|
||||
newItem: ConversationViewData
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: ConversationViewData,
|
||||
newItem: ConversationViewData
|
||||
): Boolean {
|
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
|
||||
override fun getChangePayload(
|
||||
oldItem: ConversationViewData,
|
||||
newItem: ConversationViewData
|
||||
): Any? {
|
||||
return if (oldItem == newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.Poll
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.Date
|
||||
|
||||
@Entity(primaryKeys = ["id", "accountId"])
|
||||
|
|
@ -50,6 +51,7 @@ data class ConversationEntity(
|
|||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ConversationAccountEntity(
|
||||
val id: String,
|
||||
val localUsername: String,
|
||||
|
|
@ -131,7 +133,7 @@ data class ConversationStatusEntity(
|
|||
poll = poll,
|
||||
card = null,
|
||||
language = language,
|
||||
filtered = null
|
||||
filtered = emptyList()
|
||||
),
|
||||
isExpanded = expanded,
|
||||
isShowingContent = showingHiddenContent,
|
||||
|
|
@ -140,21 +142,16 @@ data class ConversationStatusEntity(
|
|||
}
|
||||
}
|
||||
|
||||
fun TimelineAccount.toEntity() =
|
||||
ConversationAccountEntity(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis.orEmpty()
|
||||
)
|
||||
fun TimelineAccount.toEntity() = ConversationAccountEntity(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis.orEmpty()
|
||||
)
|
||||
|
||||
fun Status.toEntity(
|
||||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean
|
||||
) =
|
||||
fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) =
|
||||
ConversationStatusEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
|
|
@ -177,7 +174,7 @@ fun Status.toEntity(
|
|||
showingHiddenContent = contentShowing,
|
||||
expanded = expanded,
|
||||
collapsed = contentCollapsed,
|
||||
muted = muted ?: false,
|
||||
muted = muted,
|
||||
poll = poll,
|
||||
language = language
|
||||
)
|
||||
|
|
@ -188,16 +185,15 @@ fun Conversation.toEntity(
|
|||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean
|
||||
) =
|
||||
ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity(
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed
|
||||
)
|
||||
) = ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity(
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ class ConversationLoadStateAdapter(
|
|||
private val retryCallback: () -> Unit
|
||||
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, loadState: LoadState) {
|
||||
override fun onBindViewHolder(
|
||||
holder: BindingHolder<ItemNetworkStateBinding>,
|
||||
loadState: LoadState
|
||||
) {
|
||||
val binding = holder.binding
|
||||
binding.progressBar.visible(loadState == LoadState.Loading)
|
||||
binding.retryButton.visible(loadState is LoadState.Error)
|
||||
|
|
@ -47,7 +50,11 @@ class ConversationLoadStateAdapter(
|
|||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): BindingHolder<ItemNetworkStateBinding> {
|
||||
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val binding = ItemNetworkStateBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ data class ConversationViewData(
|
|||
accountId: Long,
|
||||
favourited: Boolean = lastStatus.status.favourited,
|
||||
bookmarked: Boolean = lastStatus.status.bookmarked,
|
||||
muted: Boolean = lastStatus.status.muted ?: false,
|
||||
muted: Boolean = lastStatus.status.muted,
|
||||
poll: Poll? = lastStatus.status.poll,
|
||||
expanded: Boolean = lastStatus.isExpanded,
|
||||
collapsed: Boolean = lastStatus.isCollapsed,
|
||||
|
|
@ -57,7 +57,7 @@ data class ConversationViewData(
|
|||
fun StatusViewData.Concrete.toConversationStatusEntity(
|
||||
favourited: Boolean = status.favourited,
|
||||
bookmarked: Boolean = status.bookmarked,
|
||||
muted: Boolean = status.muted ?: false,
|
||||
muted: Boolean = status.muted,
|
||||
poll: Poll? = status.poll,
|
||||
expanded: Boolean = isExpanded,
|
||||
collapsed: Boolean = isCollapsed,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
if (payloads == null) {
|
||||
TimelineAccount account = status.getAccount();
|
||||
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener);
|
||||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
|
|
@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
ImageView avatarView = avatars[i];
|
||||
if (i < accounts.size()) {
|
||||
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
|
||||
avatarRadius48dp, statusDisplayOptions.animateAvatars());
|
||||
avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
|
||||
avatarView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
avatarView.setVisibility(View.GONE);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import com.google.android.material.color.MaterialColors
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
|
|
@ -54,6 +55,7 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.CardViewMode
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.isAnyLoading
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
|
|
@ -61,12 +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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
|
||||
class ConversationsFragment :
|
||||
SFragment(),
|
||||
|
|
@ -89,7 +91,11 @@ class ConversationsFragment :
|
|||
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||
}
|
||||
|
||||
|
|
@ -99,14 +105,14 @@ class ConversationsFragment :
|
|||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false),
|
||||
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true),
|
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true),
|
||||
cardViewMode = CardViewMode.NONE,
|
||||
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
||||
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true),
|
||||
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false),
|
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false),
|
||||
|
|
@ -128,18 +134,37 @@ class ConversationsFragment :
|
|||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (loadState.isAnyLoading()) {
|
||||
lifecycleScope.launch {
|
||||
eventHub.dispatch(
|
||||
ConversationsLoadingEvent(
|
||||
accountManager.activeAccount?.accountId ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
when (loadState.refresh) {
|
||||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
binding.statusView.showHelp(R.string.help_empty_conversations)
|
||||
}
|
||||
}
|
||||
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
|
||||
binding.statusView.setup(
|
||||
(loadState.refresh as LoadState.Error).error
|
||||
) { refreshContent() }
|
||||
}
|
||||
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
}
|
||||
|
|
@ -223,6 +248,7 @@ class ConversationsFragment :
|
|||
refreshContent()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -231,11 +257,14 @@ class ConversationsFragment :
|
|||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||
binding.recyclerView.adapter =
|
||||
adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||
}
|
||||
|
||||
private fun refreshContent() {
|
||||
|
|
@ -263,13 +292,15 @@ class ConversationsFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null
|
||||
|
||||
override fun onMore(view: View, position: Int) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
|
||||
val popup = PopupMenu(requireContext(), view)
|
||||
popup.inflate(R.menu.conversation_more)
|
||||
|
||||
if (conversation.lastStatus.status.muted == true) {
|
||||
if (conversation.lastStatus.status.muted) {
|
||||
popup.menu.removeItem(R.id.status_mute_conversation)
|
||||
} else {
|
||||
popup.menu.removeItem(R.id.status_unmute_conversation)
|
||||
|
|
@ -289,7 +320,11 @@ class ConversationsFragment :
|
|||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
|
||||
viewMedia(
|
||||
attachmentIndex,
|
||||
AttachmentViewData.list(conversation.lastStatus.status),
|
||||
view
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,6 +396,10 @@ class ConversationsFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onUntranslate(position: Int) {
|
||||
// not needed
|
||||
}
|
||||
|
||||
private fun deleteConversation(conversation: ConversationViewData) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.dialog_delete_conversation_warning)
|
||||
|
|
@ -377,6 +416,7 @@ class ConversationsFragment :
|
|||
PrefKeys.FAB_HIDE -> {
|
||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
}
|
||||
|
||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ class ConversationsRemoteMediator(
|
|||
}
|
||||
|
||||
try {
|
||||
val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
|
||||
val conversationsResponse = api.getConversations(
|
||||
maxId = nextKey,
|
||||
limit = state.config.pageSize
|
||||
)
|
||||
|
||||
val conversations = conversationsResponse.body()
|
||||
if (!conversationsResponse.isSuccessful || conversations == null) {
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ 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 javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ConversationsViewModel @Inject constructor(
|
||||
private val timelineCases: TimelineCases,
|
||||
|
|
@ -91,7 +91,11 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
|
||||
timelineCases.voteInPoll(
|
||||
conversation.lastStatus.id,
|
||||
conversation.lastStatus.status.poll?.id!!,
|
||||
choices
|
||||
)
|
||||
.fold({ poll ->
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
|
|
@ -155,12 +159,12 @@ class ConversationsViewModel @Inject constructor(
|
|||
try {
|
||||
timelineCases.muteConversation(
|
||||
conversation.lastStatus.id,
|
||||
!(conversation.lastStatus.status.muted ?: false)
|
||||
!conversation.lastStatus.status.muted
|
||||
)
|
||||
|
||||
val newConversation = conversation.toEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
muted = !(conversation.lastStatus.status.muted ?: false)
|
||||
muted = !conversation.lastStatus.status.muted
|
||||
)
|
||||
|
||||
database.conversationDao().insert(newConversation)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package com.keylesspalace.tusky.components.instancemute
|
||||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.os.Bundle
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceListActivity : BaseActivity(), HasAndroidInjector {
|
||||
class DomainBlocksActivity : BaseActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
|
@ -28,7 +27,7 @@ class InstanceListActivity : BaseActivity(), HasAndroidInjector {
|
|||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, InstanceListFragment())
|
||||
.replace(R.id.fragment_container, DomainBlocksFragment())
|
||||
.commit()
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsAdapter.Companion.STRING_COMPARATOR
|
||||
import com.keylesspalace.tusky.databinding.ItemBlockedDomainBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
|
||||
class DomainBlocksAdapter(
|
||||
private val onUnmute: (String) -> Unit
|
||||
) : PagingDataAdapter<String, BindingHolder<ItemBlockedDomainBinding>>(STRING_COMPARATOR) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemBlockedDomainBinding> {
|
||||
val binding = ItemBlockedDomainBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemBlockedDomainBinding>, position: Int) {
|
||||
getItem(position)?.let { instance ->
|
||||
holder.binding.blockedDomain.text = instance
|
||||
holder.binding.blockedDomainUnblock.setOnClickListener {
|
||||
onUnmute(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val binding by viewBinding(FragmentDomainBlocksBinding::bind)
|
||||
|
||||
private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val adapter = DomainBlocksAdapter(viewModel::unblock)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.addItemDecoration(
|
||||
DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(view.context)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiEvents.collect { event ->
|
||||
showSnackbar(event)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.domainPager.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
binding.progressBar.visible(
|
||||
loadState.refresh == LoadState.Loading && adapter.itemCount == 0
|
||||
)
|
||||
|
||||
if (loadState.refresh is LoadState.Error) {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
val errorState = loadState.refresh as LoadState.Error
|
||||
binding.messageView.setup(errorState.error) { adapter.retry() }
|
||||
Log.w(TAG, "error loading blocked domains", errorState.error)
|
||||
} else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
|
||||
} else {
|
||||
binding.recyclerView.show()
|
||||
binding.messageView.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSnackbar(event: SnackbarEvent) {
|
||||
val message = if (event.throwable == null) {
|
||||
getString(event.message, event.domain)
|
||||
} else {
|
||||
Log.w(TAG, event.throwable)
|
||||
val error = event.throwable.localizedMessage ?: getString(R.string.ui_error_unknown)
|
||||
getString(event.message, event.domain, error)
|
||||
}
|
||||
|
||||
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
|
||||
.setTextMaxLines(5)
|
||||
.setAction(event.actionText, event.action)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DomainBlocksFragment"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
|
||||
class DomainBlocksPagingSource(
|
||||
private val domains: List<String>,
|
||||
private val nextKey: String?
|
||||
) : PagingSource<String, String>() {
|
||||
override fun getRefreshKey(state: PagingState<String, String>): String? = null
|
||||
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
|
||||
return if (params is LoadParams.Refresh) {
|
||||
LoadResult.Page(domains, null, nextKey)
|
||||
} else {
|
||||
LoadResult.Page(emptyList(), null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class DomainBlocksRemoteMediator(
|
||||
private val api: MastodonApi,
|
||||
private val repository: DomainBlocksRepository
|
||||
) : RemoteMediator<String, String>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<String, String>
|
||||
): MediatorResult {
|
||||
return try {
|
||||
val response = request(loadType)
|
||||
?: return MediatorResult.Success(endOfPaginationReached = true)
|
||||
|
||||
return applyResponse(response)
|
||||
} catch (e: Exception) {
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun request(loadType: LoadType): Response<List<String>>? {
|
||||
return when (loadType) {
|
||||
LoadType.PREPEND -> null
|
||||
LoadType.APPEND -> api.domainBlocks(maxId = repository.nextKey)
|
||||
LoadType.REFRESH -> {
|
||||
repository.nextKey = null
|
||||
repository.domains.clear()
|
||||
api.domainBlocks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyResponse(response: Response<List<String>>): MediatorResult {
|
||||
val tags = response.body()
|
||||
if (!response.isSuccessful || tags == null) {
|
||||
return MediatorResult.Error(HttpException(response))
|
||||
}
|
||||
|
||||
val links = HttpHeaderLink.parse(response.headers()["Link"])
|
||||
repository.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
|
||||
repository.domains.addAll(tags)
|
||||
repository.invalidate()
|
||||
|
||||
return MediatorResult.Success(endOfPaginationReached = repository.nextKey == null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.InvalidatingPagingSourceFactory
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import javax.inject.Inject
|
||||
|
||||
class DomainBlocksRepository @Inject constructor(
|
||||
private val api: MastodonApi
|
||||
) {
|
||||
val domains: MutableList<String> = mutableListOf()
|
||||
var nextKey: String? = null
|
||||
|
||||
private var factory = InvalidatingPagingSourceFactory {
|
||||
DomainBlocksPagingSource(domains.toList(), nextKey)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val domainPager = Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE),
|
||||
remoteMediator = DomainBlocksRemoteMediator(api, this),
|
||||
pagingSourceFactory = factory
|
||||
).flow
|
||||
|
||||
/** Invalidate the active paging source, see [PagingSource.invalidate] */
|
||||
fun invalidate() {
|
||||
factory.invalidate()
|
||||
}
|
||||
|
||||
suspend fun block(domain: String): NetworkResult<Unit> {
|
||||
return api.blockDomain(domain).onSuccess {
|
||||
domains.add(domain)
|
||||
factory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unblock(domain: String): NetworkResult<Unit> {
|
||||
return api.unblockDomain(domain).onSuccess {
|
||||
domains.remove(domain)
|
||||
factory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.keylesspalace.tusky.components.domainblocks
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import com.keylesspalace.tusky.R
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DomainBlocksViewModel @Inject constructor(
|
||||
private val repo: DomainBlocksRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val domainPager = repo.domainPager.cachedIn(viewModelScope)
|
||||
|
||||
private val _uiEvents = MutableSharedFlow<SnackbarEvent>()
|
||||
val uiEvents: SharedFlow<SnackbarEvent> = _uiEvents.asSharedFlow()
|
||||
|
||||
fun block(domain: String) {
|
||||
viewModelScope.launch {
|
||||
repo.block(domain).onFailure { e ->
|
||||
_uiEvents.emit(
|
||||
SnackbarEvent(
|
||||
message = R.string.error_blocking_domain,
|
||||
domain = domain,
|
||||
throwable = e,
|
||||
actionText = R.string.action_retry,
|
||||
action = { block(domain) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unblock(domain: String) {
|
||||
viewModelScope.launch {
|
||||
repo.unblock(domain).fold({
|
||||
_uiEvents.emit(
|
||||
SnackbarEvent(
|
||||
message = R.string.confirmation_domain_unmuted,
|
||||
domain = domain,
|
||||
throwable = null,
|
||||
actionText = R.string.action_undo,
|
||||
action = { block(domain) }
|
||||
)
|
||||
)
|
||||
}, { e ->
|
||||
_uiEvents.emit(
|
||||
SnackbarEvent(
|
||||
message = R.string.error_unblocking_domain,
|
||||
domain = domain,
|
||||
throwable = e,
|
||||
actionText = R.string.action_retry,
|
||||
action = { unblock(domain) }
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SnackbarEvent(
|
||||
@StringRes val message: Int,
|
||||
val domain: String,
|
||||
val throwable: Throwable?,
|
||||
@StringRes val actionText: Int,
|
||||
val action: (View) -> Unit
|
||||
)
|
||||
|
|
@ -29,18 +29,18 @@ 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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
|
||||
class DraftHelper @Inject constructor(
|
||||
val context: Context,
|
||||
|
|
@ -101,16 +101,17 @@ class DraftHelper @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val attachments: MutableList<DraftAttachment> = mutableListOf()
|
||||
for (i in mediaUris.indices) {
|
||||
attachments.add(
|
||||
DraftAttachment(
|
||||
uriString = uris[i].toString(),
|
||||
description = mediaDescriptions[i],
|
||||
focus = mediaFocus[i],
|
||||
type = types[i]
|
||||
val attachments: List<DraftAttachment> = buildList(mediaUris.size) {
|
||||
for (i in mediaUris.indices) {
|
||||
add(
|
||||
DraftAttachment(
|
||||
uriString = uris[i].toString(),
|
||||
description = mediaDescriptions[i],
|
||||
focus = mediaFocus[i],
|
||||
type = types[i]
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val draft = DraftEntity(
|
||||
|
|
@ -186,10 +187,8 @@ class DraftHelper @Inject constructor(
|
|||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
|
||||
val sink = file.sink().buffer()
|
||||
|
||||
response.body?.source()?.use { input ->
|
||||
sink.use { output ->
|
||||
file.sink().buffer().use { output ->
|
||||
response.body?.source()?.use { input ->
|
||||
output.writeAll(input)
|
||||
}
|
||||
}
|
||||
|
|
@ -200,6 +199,10 @@ class DraftHelper @Inject constructor(
|
|||
} else {
|
||||
this.copyToFile(contentResolver, file)
|
||||
}
|
||||
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
|
||||
return FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ class DraftMediaAdapter(
|
|||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: DraftAttachment,
|
||||
newItem: DraftAttachment
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +78,9 @@ class DraftMediaAdapter(
|
|||
RecyclerView.ViewHolder(imageView) {
|
||||
init {
|
||||
val thumbnailViewSize =
|
||||
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
|
||||
imageView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.compose_media_preview_size
|
||||
)
|
||||
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
|
||||
val margin = itemView.context.resources
|
||||
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
|
|||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
class DraftsActivity : BaseActivity(), DraftActionListener {
|
||||
|
||||
|
|
@ -74,7 +74,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
|
||||
binding.draftsRecyclerView.adapter = adapter
|
||||
binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.draftsRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
|
||||
binding.draftsRecyclerView.addItemDecoration(
|
||||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root)
|
||||
|
||||
|
|
@ -131,13 +133,21 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
|
||||
Log.w(TAG, "failed loading reply information", throwable)
|
||||
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
// the original status to which a reply was drafted has been deleted
|
||||
// let's open the ComposeActivity without reply information
|
||||
Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(
|
||||
context,
|
||||
getString(R.string.drafts_post_reply_removed),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
openDraftWithoutReply(draft)
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
getString(R.string.drafts_failed_loading_reply),
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ class DraftsAdapter(
|
|||
}
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemDraftBinding> {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemDraftBinding> {
|
||||
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
|
||||
val viewHolder = BindingHolder(binding)
|
||||
|
|
@ -77,7 +80,9 @@ class DraftsAdapter(
|
|||
holder.binding.content.text = draft.content
|
||||
|
||||
holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty())
|
||||
(holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(draft.attachments)
|
||||
(holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(
|
||||
draft.attachments
|
||||
)
|
||||
|
||||
if (draft.poll != null) {
|
||||
holder.binding.draftPoll.show()
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ import com.keylesspalace.tusky.db.AppDatabase
|
|||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DraftsViewModel @Inject constructor(
|
||||
val database: AppDatabase,
|
||||
|
|
@ -38,7 +38,11 @@ class DraftsViewModel @Inject constructor(
|
|||
|
||||
val drafts = Pager(
|
||||
config = PagingConfig(pageSize = 20),
|
||||
pagingSourceFactory = { database.draftDao().draftsPagingSource(accountManager.activeAccount?.id!!) }
|
||||
pagingSourceFactory = {
|
||||
database.draftDao().draftsPagingSource(
|
||||
accountManager.activeAccount?.id!!
|
||||
)
|
||||
}
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface.BUTTON_POSITIVE
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
|
|
@ -18,18 +19,19 @@ import com.google.android.material.switchmaterial.SwitchMaterial
|
|||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding
|
||||
import com.keylesspalace.tusky.databinding.DialogFilterBinding
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterKeyword
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EditFilterActivity : BaseActivity() {
|
||||
@Inject
|
||||
|
|
@ -81,7 +83,11 @@ class EditFilterActivity : BaseActivity() {
|
|||
|
||||
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
|
||||
binding.filterSaveButton.setOnClickListener { saveChanges() }
|
||||
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
|
||||
binding.filterDeleteButton.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter()
|
||||
}
|
||||
}
|
||||
binding.filterDeleteButton.visible(originalFilter != null)
|
||||
|
||||
for (switch in contextSwitches.keys) {
|
||||
|
|
@ -109,7 +115,12 @@ class EditFilterActivity : BaseActivity() {
|
|||
)
|
||||
}
|
||||
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>?,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long
|
||||
) {
|
||||
viewModel.setDuration(
|
||||
if (originalFilter?.expiresAt == null) {
|
||||
position
|
||||
|
|
@ -259,8 +270,17 @@ class EditFilterActivity : BaseActivity() {
|
|||
lifecycleScope.launch {
|
||||
if (viewModel.saveChanges(this@EditFilterActivity)) {
|
||||
finish()
|
||||
// Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter
|
||||
val affectedContexts = viewModel.contexts.value.map {
|
||||
it.kind
|
||||
}.union(originalFilter?.context ?: listOf()).distinct()
|
||||
eventHub.dispatch(FilterUpdatedEvent(affectedContexts))
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
"Error saving filter '${viewModel.title.value}'",
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,17 +293,25 @@ class EditFilterActivity : BaseActivity() {
|
|||
finish()
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
"Error deleting filter '${filter.title}'",
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(
|
||||
binding.root,
|
||||
"Error deleting filter '${filter.title}'",
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -298,7 +326,11 @@ class EditFilterActivity : BaseActivity() {
|
|||
// 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() }
|
||||
-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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,82 +8,94 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.FilterKeyword
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import retrofit2.HttpException
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
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() {
|
||||
private var originalFilter: Filter? = null
|
||||
val title = MutableStateFlow("")
|
||||
val keywords = MutableStateFlow(listOf<FilterKeyword>())
|
||||
val action = MutableStateFlow(Filter.Action.WARN)
|
||||
val duration = MutableStateFlow(0)
|
||||
val contexts = MutableStateFlow(listOf<Filter.Kind>())
|
||||
|
||||
private val _title = MutableStateFlow("")
|
||||
val title: StateFlow<String> = _title.asStateFlow()
|
||||
|
||||
private val _keywords = MutableStateFlow(listOf<FilterKeyword>())
|
||||
val keywords: StateFlow<List<FilterKeyword>> = _keywords.asStateFlow()
|
||||
|
||||
private val _action = MutableStateFlow(Filter.Action.WARN)
|
||||
val action: StateFlow<Filter.Action> = _action.asStateFlow()
|
||||
|
||||
private val _duration = MutableStateFlow(0)
|
||||
val duration: StateFlow<Int> = _duration.asStateFlow()
|
||||
|
||||
private val _contexts = MutableStateFlow(listOf<Filter.Kind>())
|
||||
val contexts: StateFlow<List<Filter.Kind>> = _contexts.asStateFlow()
|
||||
|
||||
fun load(filter: Filter) {
|
||||
originalFilter = filter
|
||||
title.value = filter.title
|
||||
keywords.value = filter.keywords
|
||||
action.value = filter.action
|
||||
duration.value = if (filter.expiresAt == null) {
|
||||
_title.value = filter.title
|
||||
_keywords.value = filter.keywords
|
||||
_action.value = filter.action
|
||||
_duration.value = if (filter.expiresAt == null) {
|
||||
0
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
contexts.value = filter.kinds
|
||||
_contexts.value = filter.kinds
|
||||
}
|
||||
|
||||
fun addKeyword(keyword: FilterKeyword) {
|
||||
keywords.value += keyword
|
||||
_keywords.value += keyword
|
||||
}
|
||||
|
||||
fun deleteKeyword(keyword: FilterKeyword) {
|
||||
keywords.value = keywords.value.filterNot { it == keyword }
|
||||
_keywords.value = _keywords.value.filterNot { it == keyword }
|
||||
}
|
||||
|
||||
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) {
|
||||
val index = keywords.value.indexOf(original)
|
||||
val index = _keywords.value.indexOf(original)
|
||||
if (index >= 0) {
|
||||
keywords.value = keywords.value.toMutableList().apply {
|
||||
_keywords.value = _keywords.value.toMutableList().apply {
|
||||
set(index, updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(title: String) {
|
||||
this.title.value = title
|
||||
this._title.value = title
|
||||
}
|
||||
|
||||
fun setDuration(index: Int) {
|
||||
duration.value = index
|
||||
_duration.value = index
|
||||
}
|
||||
|
||||
fun setAction(action: Filter.Action) {
|
||||
this.action.value = action
|
||||
this._action.value = action
|
||||
}
|
||||
|
||||
fun addContext(context: Filter.Kind) {
|
||||
if (!contexts.value.contains(context)) {
|
||||
contexts.value += context
|
||||
if (!_contexts.value.contains(context)) {
|
||||
_contexts.value += context
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContext(context: Filter.Kind) {
|
||||
contexts.value = contexts.value.filter { it != context }
|
||||
_contexts.value = _contexts.value.filter { it != context }
|
||||
}
|
||||
|
||||
fun validate(): Boolean {
|
||||
return title.value.isNotBlank() &&
|
||||
keywords.value.isNotEmpty() &&
|
||||
contexts.value.isNotEmpty()
|
||||
return _title.value.isNotBlank() &&
|
||||
_keywords.value.isNotEmpty() &&
|
||||
_contexts.value.isNotEmpty()
|
||||
}
|
||||
|
||||
suspend fun saveChanges(context: Context): Boolean {
|
||||
val contexts = contexts.value.map { it.kind }
|
||||
val title = title.value
|
||||
val durationIndex = duration.value
|
||||
val action = action.value.action
|
||||
val contexts = _contexts.value.map { it.kind }
|
||||
val title = _title.value
|
||||
val durationIndex = _duration.value
|
||||
val action = _action.value.action
|
||||
|
||||
return withContext(viewModelScope.coroutineContext) {
|
||||
originalFilter?.let { filter ->
|
||||
|
|
@ -92,7 +104,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun createFilter(title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
|
||||
private suspend fun createFilter(
|
||||
title: String,
|
||||
contexts: List<String>,
|
||||
action: String,
|
||||
durationIndex: Int,
|
||||
context: Context
|
||||
): Boolean {
|
||||
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
|
||||
api.createFilter(
|
||||
title = title,
|
||||
|
|
@ -102,13 +120,17 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
).fold(
|
||||
{ newFilter ->
|
||||
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||
return keywords.value.map { keyword ->
|
||||
api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
return _keywords.value.map { keyword ->
|
||||
api.addFilterKeyword(
|
||||
filterId = newFilter.id,
|
||||
keyword = keyword.keyword,
|
||||
wholeWord = keyword.wholeWord
|
||||
)
|
||||
}.none { it.isFailure }
|
||||
},
|
||||
{ throwable ->
|
||||
return (
|
||||
throwable is HttpException && throwable.code() == 404 &&
|
||||
throwable.isHttpNotFound() &&
|
||||
// Endpoint not found, fall back to v1 api
|
||||
createFilterV1(contexts, expiresInSeconds)
|
||||
)
|
||||
|
|
@ -116,7 +138,14 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
|
||||
private suspend fun updateFilter(
|
||||
originalFilter: Filter,
|
||||
title: String,
|
||||
contexts: List<String>,
|
||||
action: String,
|
||||
durationIndex: Int,
|
||||
context: Context
|
||||
): Boolean {
|
||||
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
|
||||
api.updateFilter(
|
||||
id = originalFilter.id,
|
||||
|
|
@ -127,7 +156,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
).fold(
|
||||
{
|
||||
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||
val results = keywords.value.map { keyword ->
|
||||
val results = _keywords.value.map { keyword ->
|
||||
if (keyword.id.isEmpty()) {
|
||||
api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
} else {
|
||||
|
|
@ -135,13 +164,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
}
|
||||
} + originalFilter.keywords.filter { keyword ->
|
||||
// Deleted keywords
|
||||
keywords.value.none { it.id == keyword.id }
|
||||
_keywords.value.none { it.id == keyword.id }
|
||||
}.map { api.deleteFilterKeyword(it.id) }
|
||||
|
||||
return results.none { it.isFailure }
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
// Endpoint not found, fall back to v1 api
|
||||
if (updateFilterV1(contexts, expiresInSeconds)) {
|
||||
return true
|
||||
|
|
@ -153,13 +182,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
|
|||
}
|
||||
|
||||
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
|
||||
return keywords.value.map { keyword ->
|
||||
return _keywords.value.map { keyword ->
|
||||
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
|
||||
}.none { it.isFailure }
|
||||
}
|
||||
|
||||
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
|
||||
val results = keywords.value.map { keyword ->
|
||||
val results = _keywords.value.map { keyword ->
|
||||
if (originalFilter == null) {
|
||||
api.createFilterV1(
|
||||
phrase = keyword.keyword,
|
||||
|
|
|
|||
|
|
@ -15,24 +15,17 @@
|
|||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
import android.app.Activity
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.util.await
|
||||
|
||||
/** Show load state and retry options when loading notifications */
|
||||
class NotificationsLoadStateAdapter(
|
||||
private val retry: () -> Unit
|
||||
) : LoadStateAdapter<NotificationsLoadStateViewHolder>() {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): NotificationsLoadStateViewHolder {
|
||||
return NotificationsLoadStateViewHolder.create(parent, retry)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) {
|
||||
holder.bind(loadState)
|
||||
}
|
||||
}
|
||||
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(
|
||||
this
|
||||
)
|
||||
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
|
||||
.setCancelable(true)
|
||||
.create()
|
||||
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.keylesspalace.tusky.components.filters
|
||||
|
||||
import android.content.DialogInterface.BUTTON_POSITIVE
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
|
|
@ -11,10 +12,11 @@ import com.keylesspalace.tusky.di.ViewModelFactory
|
|||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FiltersActivity : BaseActivity(), FiltersListener {
|
||||
@Inject
|
||||
|
|
@ -53,25 +55,36 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
binding.progressBar.visible(state.loadingState == FiltersViewModel.LoadingState.LOADING)
|
||||
binding.progressBar.visible(
|
||||
state.loadingState == FiltersViewModel.LoadingState.LOADING
|
||||
)
|
||||
binding.swipeRefreshLayout.isRefreshing = state.loadingState == FiltersViewModel.LoadingState.LOADING
|
||||
binding.addFilterButton.visible(state.loadingState == FiltersViewModel.LoadingState.LOADED)
|
||||
binding.addFilterButton.visible(
|
||||
state.loadingState == FiltersViewModel.LoadingState.LOADED
|
||||
)
|
||||
|
||||
when (state.loadingState) {
|
||||
FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide()
|
||||
FiltersViewModel.LoadingState.ERROR_NETWORK -> {
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.setup(
|
||||
R.drawable.errorphant_offline,
|
||||
R.string.error_network
|
||||
) {
|
||||
loadFilters()
|
||||
}
|
||||
binding.messageView.show()
|
||||
}
|
||||
FiltersViewModel.LoadingState.ERROR_OTHER -> {
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.setup(
|
||||
R.drawable.errorphant_error,
|
||||
R.string.error_generic
|
||||
) {
|
||||
loadFilters()
|
||||
}
|
||||
binding.messageView.show()
|
||||
}
|
||||
FiltersViewModel.LoadingState.LOADED -> {
|
||||
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
|
||||
if (state.filters.isEmpty()) {
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
|
|
@ -81,7 +94,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
binding.messageView.show()
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,12 +111,15 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
putExtra(EditFilterActivity.FILTER_TO_EDIT, filter)
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
|
||||
startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
|
||||
override fun deleteFilter(filter: Filter) {
|
||||
viewModel.deleteFilter(filter, binding.root)
|
||||
lifecycleScope.launch {
|
||||
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) {
|
||||
viewModel.deleteFilter(filter, binding.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateFilter(updatedFilter: Filter) {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,13 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
|
|||
|
||||
override fun getItemCount(): Int = filters.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemRemovableBinding> {
|
||||
return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemRemovableBinding> {
|
||||
return BindingHolder(
|
||||
ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemRemovableBinding>, position: Int) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import com.keylesspalace.tusky.util.isHttpNotFound
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FiltersViewModel @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
|
|
@ -21,13 +22,17 @@ class FiltersViewModel @Inject constructor(
|
|||
) : ViewModel() {
|
||||
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
INITIAL,
|
||||
LOADING,
|
||||
LOADED,
|
||||
ERROR_NETWORK,
|
||||
ERROR_OTHER
|
||||
}
|
||||
|
||||
data class State(val filters: List<Filter>, val loadingState: LoadingState)
|
||||
|
||||
val state: Flow<State> get() = _state
|
||||
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
fun load() {
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
|
||||
|
|
@ -38,14 +43,13 @@ class FiltersViewModel @Inject constructor(
|
|||
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
api.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
|
||||
},
|
||||
{ throwable ->
|
||||
{ _ ->
|
||||
// TODO log errors (also below)
|
||||
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
|
||||
}
|
||||
)
|
||||
|
|
@ -62,23 +66,41 @@ class FiltersViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
api.deleteFilter(filter.id).fold(
|
||||
{
|
||||
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
|
||||
this@FiltersViewModel._state.value = State(
|
||||
this@FiltersViewModel._state.value.filters.filter {
|
||||
it.id != filter.id
|
||||
},
|
||||
LoadingState.LOADED
|
||||
)
|
||||
for (context in filter.context) {
|
||||
eventHub.dispatch(PreferenceChangedEvent(context))
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
if (throwable.isHttpNotFound()) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
|
||||
this@FiltersViewModel._state.value = State(
|
||||
this@FiltersViewModel._state.value.filters.filter {
|
||||
it.id != filter.id
|
||||
},
|
||||
LoadingState.LOADED
|
||||
)
|
||||
},
|
||||
{
|
||||
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
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,
|
||||
"Error deleting filter '${filter.title}'",
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ 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 kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class FollowedTagsActivity :
|
||||
BaseActivity(),
|
||||
|
|
@ -81,7 +81,9 @@ class FollowedTagsActivity :
|
|||
binding.followedTagsView.adapter = adapter
|
||||
binding.followedTagsView.setHasFixedSize(true)
|
||||
binding.followedTagsView.layoutManager = LinearLayoutManager(this)
|
||||
binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
|
||||
binding.followedTagsView.addItemDecoration(
|
||||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
(binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
val hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
|
|
@ -101,7 +103,9 @@ class FollowedTagsActivity :
|
|||
private fun setupAdapter(): FollowedTagsAdapter {
|
||||
return FollowedTagsAdapter(this, viewModel).apply {
|
||||
addLoadStateListener { loadState ->
|
||||
binding.followedTagsProgressBar.visible(loadState.refresh == LoadState.Loading && itemCount == 0)
|
||||
binding.followedTagsProgressBar.visible(
|
||||
loadState.refresh == LoadState.Loading && itemCount == 0
|
||||
)
|
||||
|
||||
if (loadState.refresh is LoadState.Error) {
|
||||
binding.followedTagsView.hide()
|
||||
|
|
@ -145,7 +149,7 @@ class FollowedTagsActivity :
|
|||
lifecycleScope.launch {
|
||||
api.unfollowTag(tagName).fold(
|
||||
{
|
||||
viewModel.tags.removeAt(position)
|
||||
viewModel.tags.removeIf { tag -> tag.name == tagName }
|
||||
Snackbar.make(
|
||||
this@FollowedTagsActivity,
|
||||
binding.followedTagsView,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,22 @@ class FollowedTagsAdapter(
|
|||
private val actionListener: HashtagActionListener,
|
||||
private val viewModel: FollowedTagsViewModel
|
||||
) : PagingDataAdapter<String, BindingHolder<ItemFollowedHashtagBinding>>(STRING_COMPARATOR) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowedHashtagBinding> =
|
||||
BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemFollowedHashtagBinding> = BindingHolder(
|
||||
ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemFollowedHashtagBinding>, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
holder: BindingHolder<ItemFollowedHashtagBinding>,
|
||||
position: Int
|
||||
) {
|
||||
viewModel.tags[position].let { tag ->
|
||||
holder.itemView.findViewById<TextView>(R.id.followed_tag).text = tag.name
|
||||
holder.itemView.findViewById<ImageButton>(R.id.followed_tag_unfollow).setOnClickListener {
|
||||
holder.itemView.findViewById<ImageButton>(
|
||||
R.id.followed_tag_unfollow
|
||||
).setOnClickListener {
|
||||
actionListener.unfollow(tag.name, holder.bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,8 +40,10 @@ class FollowedTagsAdapter(
|
|||
|
||||
companion object {
|
||||
val STRING_COMPARATOR = object : DiffUtil.ItemCallback<String>() {
|
||||
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem
|
||||
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem
|
||||
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean =
|
||||
oldItem == newItem
|
||||
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean =
|
||||
oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.keylesspalace.tusky.di.Injectable
|
|||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class FollowedTagsViewModel @Inject constructor(
|
||||
private val api: MastodonApi
|
||||
|
|
@ -35,14 +36,22 @@ class FollowedTagsViewModel @Inject constructor(
|
|||
}
|
||||
).flow.cachedIn(viewModelScope)
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map { ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(it.name) }
|
||||
}, { e ->
|
||||
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||
emptyList()
|
||||
})
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -28,5 +28,7 @@ data class InstanceInfo(
|
|||
val maxMediaAttachments: Int,
|
||||
val maxFields: Int,
|
||||
val maxFieldNameLength: Int?,
|
||||
val maxFieldValueLength: Int?
|
||||
val maxFieldValueLength: Int?,
|
||||
val version: String?,
|
||||
val translationEnabled: Boolean?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,27 +16,64 @@
|
|||
package com.keylesspalace.tusky.components.instanceinfo
|
||||
|
||||
import android.util.Log
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import at.connyduck.calladapter.networkresult.map
|
||||
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.di.ApplicationScope
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.entity.InstanceV1
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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,
|
||||
accountManager: AccountManager
|
||||
private val accountManager: AccountManager,
|
||||
@ApplicationScope
|
||||
private val externalScope: CoroutineScope
|
||||
) {
|
||||
|
||||
private val dao = db.instanceDao()
|
||||
private val instanceName = accountManager.activeAccount!!.domain
|
||||
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.
|
||||
// We might also get the results in random order or write them twice but it's also
|
||||
// not a problem.
|
||||
// We are just trying to avoid 2 things:
|
||||
// - fetching it when we already have it
|
||||
// - 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cachedInstanceInfoOrFallback: InstanceInfo
|
||||
get() = instanceInfoCache[instanceName] ?: null.toInfoOrDefault()
|
||||
|
||||
/**
|
||||
* Returns the custom emojis of the instance.
|
||||
|
|
@ -57,53 +94,114 @@ class InstanceInfoRepository @Inject constructor(
|
|||
* Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available.
|
||||
* Never throws, returns defaults of vanilla Mastodon in case of error.
|
||||
*/
|
||||
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
|
||||
api.getInstance()
|
||||
.fold(
|
||||
{ instance ->
|
||||
val instanceEntity = InstanceInfoEntity(
|
||||
instance = instanceName,
|
||||
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
|
||||
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
|
||||
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
|
||||
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
|
||||
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
|
||||
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
|
||||
version = instance.version,
|
||||
videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit,
|
||||
imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit,
|
||||
imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit,
|
||||
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments,
|
||||
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
|
||||
suspend fun getUpdatedInstanceInfoOrFallback(): InstanceInfo =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchAndPersistInstanceInfo()
|
||||
.getOrElse { throwable ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"failed to load instance, falling back to cache and default values",
|
||||
throwable
|
||||
)
|
||||
dao.upsert(instanceEntity)
|
||||
instanceEntity
|
||||
},
|
||||
{ throwable ->
|
||||
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
|
||||
dao.getInstanceInfo(instanceName)
|
||||
}
|
||||
).let { instanceInfo: InstanceInfoEntity? ->
|
||||
InstanceInfo(
|
||||
maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
|
||||
pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
|
||||
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||
videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT,
|
||||
imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT,
|
||||
imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
||||
maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
||||
maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
|
||||
maxFieldNameLength = instanceInfo?.maxFieldNameLength,
|
||||
maxFieldValueLength = instanceInfo?.maxFieldValueLength
|
||||
)
|
||||
}.toInfoOrDefault()
|
||||
|
||||
private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult<InstanceInfoEntity> =
|
||||
fetchRemoteInstanceInfo()
|
||||
.onSuccess { instanceInfoEntity ->
|
||||
dao.upsert(instanceInfoEntity)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteInstanceInfo(): NetworkResult<InstanceInfoEntity> {
|
||||
val instance = this.instanceName
|
||||
return api.getInstance()
|
||||
.map { it.toEntity() }
|
||||
.recoverCatching { t ->
|
||||
if (t.isHttpNotFound()) {
|
||||
api.getInstanceV1().map { it.toEntity(instance) }.getOrThrow()
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun InstanceInfoEntity?.toInfoOrDefault() = InstanceInfo(
|
||||
maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
pollMaxOptions = this?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
|
||||
pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
|
||||
charactersReservedPerUrl = this?.charactersReservedPerUrl
|
||||
?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||
videoSizeLimit = this?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT,
|
||||
imageSizeLimit = this?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT,
|
||||
imageMatrixLimit = this?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
||||
maxMediaAttachments = this?.maxMediaAttachments
|
||||
?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
||||
maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
|
||||
maxFieldNameLength = this?.maxFieldNameLength,
|
||||
maxFieldValueLength = this?.maxFieldValueLength,
|
||||
version = this?.version,
|
||||
translationEnabled = this?.translationEnabled
|
||||
)
|
||||
|
||||
private fun Instance.toEntity() = InstanceInfoEntity(
|
||||
instance = domain,
|
||||
maximumTootCharacters = this.configuration?.statuses?.maxCharacters
|
||||
?: DEFAULT_CHARACTER_LIMIT,
|
||||
maxPollOptions = this.configuration?.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption
|
||||
?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
minPollDuration = this.configuration?.polls?.minExpirationSeconds
|
||||
?: DEFAULT_MIN_POLL_DURATION,
|
||||
maxPollDuration = this.configuration?.polls?.maxExpirationSeconds
|
||||
?: DEFAULT_MAX_POLL_DURATION,
|
||||
charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl
|
||||
?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||
version = this.version,
|
||||
videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimitBytes?.toInt()
|
||||
?: DEFAULT_VIDEO_SIZE_LIMIT,
|
||||
imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimitBytes?.toInt()
|
||||
?: DEFAULT_IMAGE_SIZE_LIMIT,
|
||||
imageMatrixLimit = this.configuration?.mediaAttachments?.imagePixelCountLimit?.toInt()
|
||||
?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
||||
maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments
|
||||
?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
||||
maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||
maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||
maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength,
|
||||
translationEnabled = this.configuration?.translation?.enabled
|
||||
)
|
||||
|
||||
private fun InstanceV1.toEntity(instanceName: String) =
|
||||
InstanceInfoEntity(
|
||||
instance = instanceName,
|
||||
maximumTootCharacters = this.configuration?.statuses?.maxCharacters
|
||||
?: this.maxTootChars,
|
||||
maxPollOptions = this.configuration?.polls?.maxOptions
|
||||
?: this.pollConfiguration?.maxOptions,
|
||||
maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption
|
||||
?: this.pollConfiguration?.maxOptionChars,
|
||||
minPollDuration = this.configuration?.polls?.minExpiration
|
||||
?: this.pollConfiguration?.minExpiration,
|
||||
maxPollDuration = this.configuration?.polls?.maxExpiration
|
||||
?: this.pollConfiguration?.maxExpiration,
|
||||
charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl,
|
||||
version = this.version,
|
||||
videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimit
|
||||
?: this.uploadLimit,
|
||||
imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimit
|
||||
?: this.uploadLimit,
|
||||
imageMatrixLimit = this.configuration?.mediaAttachments?.imageMatrixLimit,
|
||||
maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments
|
||||
?: this.maxMediaAttachments,
|
||||
maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||
maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||
maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength,
|
||||
translationEnabled = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InstanceInfoRepo"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.instancemute.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
|
||||
import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
|
||||
class DomainMutesAdapter(
|
||||
private val actionListener: InstanceActionListener
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemMutedDomainBinding>>() {
|
||||
|
||||
var instances: MutableList<String> = mutableListOf()
|
||||
var bottomLoading: Boolean = false
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemMutedDomainBinding> {
|
||||
val binding = ItemMutedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemMutedDomainBinding>, position: Int) {
|
||||
val instance = instances[position]
|
||||
|
||||
holder.binding.mutedDomain.text = instance
|
||||
holder.binding.mutedDomainUnmute.setOnClickListener {
|
||||
actionListener.mute(false, instance, holder.bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
var count = instances.size
|
||||
if (bottomLoading) {
|
||||
++count
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
fun addItems(newInstances: List<String>) {
|
||||
val end = instances.size
|
||||
instances.addAll(newInstances)
|
||||
notifyItemRangeInserted(end, instances.size)
|
||||
}
|
||||
|
||||
fun addItem(instance: String) {
|
||||
instances.add(instance)
|
||||
notifyItemInserted(instances.size)
|
||||
}
|
||||
|
||||
fun removeItem(position: Int) {
|
||||
if (position >= 0 && position < instances.size) {
|
||||
instances.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.instancemute.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
|
||||
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
|
||||
import com.keylesspalace.tusky.databinding.FragmentInstanceListBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
|
||||
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
private val binding by viewBinding(FragmentInstanceListBinding::bind)
|
||||
|
||||
private var fetching = false
|
||||
private var bottomId: String? = null
|
||||
private var adapter = DomainMutesAdapter(this)
|
||||
private lateinit var scrollListener: EndlessOnScrollListener
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
|
||||
scrollListener = object : EndlessOnScrollListener(layoutManager) {
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||
if (bottomId != null) {
|
||||
fetchInstances(bottomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.recyclerView.addOnScrollListener(scrollListener)
|
||||
fetchInstances()
|
||||
}
|
||||
|
||||
override fun mute(mute: Boolean, instance: String, position: Int) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
if (mute) {
|
||||
api.blockDomain(instance).fold({
|
||||
adapter.addItem(instance)
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error muting domain $instance", e)
|
||||
})
|
||||
} else {
|
||||
api.unblockDomain(instance).fold({
|
||||
adapter.removeItem(position)
|
||||
Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_undo) {
|
||||
mute(true, instance, position)
|
||||
}
|
||||
.show()
|
||||
}, { e ->
|
||||
Log.e(TAG, "Error unmuting domain $instance", e)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchInstances(id: String? = null) {
|
||||
if (fetching) {
|
||||
return
|
||||
}
|
||||
fetching = true
|
||||
binding.instanceProgressBar.show()
|
||||
|
||||
if (id != null) {
|
||||
binding.recyclerView.post { adapter.bottomLoading = true }
|
||||
}
|
||||
|
||||
api.domainBlocks(id, bottomId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
{ response ->
|
||||
val instances = response.body()
|
||||
|
||||
if (response.isSuccessful && instances != null) {
|
||||
onFetchInstancesSuccess(instances, response.headers()["Link"])
|
||||
} else {
|
||||
onFetchInstancesFailure(Exception(response.message()))
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
onFetchInstancesFailure(throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) {
|
||||
adapter.bottomLoading = false
|
||||
binding.instanceProgressBar.hide()
|
||||
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
val next = HttpHeaderLink.findByRelationType(links, "next")
|
||||
val fromId = next?.uri?.getQueryParameter("max_id")
|
||||
adapter.addItems(instances)
|
||||
bottomId = fromId
|
||||
fetching = false
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.message_empty,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
binding.messageView.hide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFetchInstancesFailure(throwable: Throwable) {
|
||||
fetching = false
|
||||
binding.instanceProgressBar.hide()
|
||||
Log.e(TAG, "Fetch failure", throwable)
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(throwable) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InstanceList" // logging tag
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.instancemute.interfaces
|
||||
|
||||
interface InstanceActionListener {
|
||||
fun mute(mute: Boolean, instance: String, position: Int)
|
||||
}
|
||||
|
|
@ -42,10 +42,11 @@ import com.keylesspalace.tusky.util.getNonNullString
|
|||
import com.keylesspalace.tusky.util.openLinkInCustomTab
|
||||
import com.keylesspalace.tusky.util.rickRoll
|
||||
import com.keylesspalace.tusky.util.shouldRickRoll
|
||||
import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Main login page, the first thing that users see. Has prompt for instance and login button. */
|
||||
class LoginActivity : BaseActivity(), Injectable {
|
||||
|
|
@ -125,13 +126,6 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
return false
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
if (isAdditionalLogin() || isAccountMigration()) {
|
||||
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menu?.add(R.string.action_browser_login)?.apply {
|
||||
setOnMenuItemClickListener {
|
||||
|
|
@ -215,7 +209,11 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String, openInWebView: Boolean) {
|
||||
private fun redirectUserToAuthorizeAndLogin(
|
||||
domain: String,
|
||||
clientId: String,
|
||||
openInWebView: Boolean
|
||||
) {
|
||||
// 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()
|
||||
|
|
@ -330,10 +328,13 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
intent.putExtra(MainActivity.OPEN_WITH_EXPLODE_ANIMATION, true)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
overridePendingTransition(R.anim.explode, R.anim.explode)
|
||||
finishAffinity()
|
||||
if (!supportsOverridingActivityTransitions()) {
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(R.anim.explode, R.anim.activity_open_exit)
|
||||
}
|
||||
}, { e ->
|
||||
setLoading(false)
|
||||
binding.domainTextInputLayout.error =
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ import com.keylesspalace.tusky.di.ViewModelFactory
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Contract for starting [LoginWebViewActivity]. */
|
||||
class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
|
||||
|
|
@ -91,15 +91,15 @@ data class LoginData(
|
|||
val oauthRedirectUrl: Uri
|
||||
) : Parcelable
|
||||
|
||||
sealed class LoginResult : Parcelable {
|
||||
sealed interface LoginResult : Parcelable {
|
||||
@Parcelize
|
||||
data class Ok(val code: String) : LoginResult()
|
||||
data class Ok(val code: String) : LoginResult
|
||||
|
||||
@Parcelize
|
||||
data class Err(val errorMessage: String) : LoginResult()
|
||||
data class Err(val errorMessage: String) : LoginResult
|
||||
|
||||
@Parcelize
|
||||
object Cancel : LoginResult()
|
||||
data object Cancel : LoginResult
|
||||
}
|
||||
|
||||
/** Activity to do Oauth process using WebView. */
|
||||
|
|
@ -227,14 +227,10 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
|
|||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
override fun requiresLogin() = false
|
||||
|
||||
private fun sendResult(result: LoginResult) {
|
||||
setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result))
|
||||
finishWithoutSlideOutAnimation()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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