Merge tag 'v19.0' into develop
This commit is contained in:
commit
eeeb6a8599
160 changed files with 6469 additions and 1305 deletions
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
<application
|
||||
android:name=".TuskyApplication"
|
||||
android:appCategory="social"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_chinwag_logo_simple"
|
||||
android:label="@string/app_name"
|
||||
|
|
@ -146,6 +147,29 @@
|
|||
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".receiver.UnifiedPushBroadcastReceiver"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".receiver.NotificationBlockStateBroadcastReceiver"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED"/>
|
||||
<action android:name="android.app.action.NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".service.TuskyTileService"
|
||||
|
|
|
|||
|
|
@ -24,12 +24,11 @@ import android.widget.LinearLayout
|
|||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
|
|
@ -45,7 +44,7 @@ import com.keylesspalace.tusky.util.show
|
|||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.State
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -98,10 +97,8 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context)
|
||||
binding.accountsSearchRecycler.adapter = searchAdapter
|
||||
|
||||
viewModel.state
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe { state ->
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.state.collect { state ->
|
||||
adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
|
||||
|
||||
when (state.accounts) {
|
||||
|
|
@ -111,6 +108,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
|
||||
setupSearchView(state)
|
||||
}
|
||||
}
|
||||
|
||||
binding.searchView.isSubmitButtonEnabled = true
|
||||
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
|
|
|
|||
|
|
@ -31,14 +31,13 @@ import android.widget.TextView
|
|||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.databinding.ActivityListsBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
|
|
@ -63,7 +62,7 @@ import com.mikepenz.iconics.utils.colorInt
|
|||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
|
@ -102,19 +101,18 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
||||
)
|
||||
|
||||
viewModel.state
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe(this::update)
|
||||
lifecycleScope.launch {
|
||||
viewModel.state.collect(this@ListsActivity::update)
|
||||
}
|
||||
|
||||
viewModel.retryLoading()
|
||||
|
||||
binding.addListButton.setOnClickListener {
|
||||
showlistNameDialog(null)
|
||||
}
|
||||
|
||||
viewModel.events.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe { event ->
|
||||
lifecycleScope.launch {
|
||||
viewModel.events.collect { event ->
|
||||
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
|
||||
when (event) {
|
||||
Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
|
||||
|
|
@ -122,6 +120,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showlistNameDialog(list: MastoList?) {
|
||||
|
|
@ -198,9 +197,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
).show()
|
||||
}
|
||||
|
||||
private fun onListSelected(listId: String) {
|
||||
private fun onListSelected(listId: String, listTitle: String) {
|
||||
startActivityWithSlideInAnimation(
|
||||
StatusListActivity.newListIntent(this, listId)
|
||||
StatusListActivity.newListIntent(this, listId, listTitle)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -270,7 +269,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
|
||||
override fun onClick(v: View) {
|
||||
if (v == itemView) {
|
||||
onListSelected(getItem(bindingAdapterPosition).id)
|
||||
val list = getItem(bindingAdapterPosition)
|
||||
onListSelected(list.id, list.title)
|
||||
} else {
|
||||
onMore(getItem(bindingAdapterPosition), v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.lifecycle.Lifecycle
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import autodispose2.androidx.lifecycle.autoDispose
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
|
|
@ -60,11 +61,12 @@ import com.keylesspalace.tusky.components.account.AccountActivity
|
|||
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsRepository
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.drafts.DraftsActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.disableAllNotifications
|
||||
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
|
||||
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesActivity
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
|
|
@ -76,11 +78,12 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
|||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.pager.MainPagerAdapter
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.LogoutUsecase
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.removeShortcut
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.updateShortcut
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
|
@ -130,10 +133,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
lateinit var cacheUpdater: CacheUpdater
|
||||
|
||||
@Inject
|
||||
lateinit var conversationRepository: ConversationsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var draftHelper: DraftHelper
|
||||
lateinit var logoutUsecase: LogoutUsecase
|
||||
|
||||
private val binding by viewBinding(ActivityMainBinding::inflate)
|
||||
|
||||
|
|
@ -242,12 +242,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
setupTabs(showNotificationTab)
|
||||
|
||||
// Setup push notifications
|
||||
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
|
||||
NotificationHelper.enablePullNotifications(this)
|
||||
} else {
|
||||
NotificationHelper.disablePullNotifications(this)
|
||||
}
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
|
|
@ -636,7 +630,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
// open LoginActivity to add new account
|
||||
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
|
||||
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true))
|
||||
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN))
|
||||
return false
|
||||
}
|
||||
// change Account
|
||||
|
|
@ -665,24 +659,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
.setTitle(R.string.action_logout)
|
||||
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
|
||||
binding.appBar.hide()
|
||||
binding.viewPager.hide()
|
||||
binding.progressBar.show()
|
||||
binding.bottomNav.hide()
|
||||
binding.composeButton.hide()
|
||||
|
||||
lifecycleScope.launch {
|
||||
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
|
||||
cacheUpdater.clearForUser(activeAccount.id)
|
||||
conversationRepository.deleteCacheForAccount(activeAccount.id)
|
||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
||||
removeShortcut(this@MainActivity, activeAccount)
|
||||
val newAccount = accountManager.logActiveAccountOut()
|
||||
if (!NotificationHelper.areNotificationsEnabled(
|
||||
this@MainActivity,
|
||||
accountManager
|
||||
)
|
||||
) {
|
||||
NotificationHelper.disablePullNotifications(this@MainActivity)
|
||||
}
|
||||
val intent = if (newAccount == null) {
|
||||
LoginActivity.getIntent(this@MainActivity, false)
|
||||
} else {
|
||||
val otherAccountAvailable = logoutUsecase.logout()
|
||||
val intent = if (otherAccountAvailable) {
|
||||
Intent(this@MainActivity, MainActivity::class.java)
|
||||
} else {
|
||||
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
|
||||
}
|
||||
startActivity(intent)
|
||||
finishWithoutSlideOutAnimation()
|
||||
|
|
@ -714,6 +702,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
accountManager.updateActiveAccount(me)
|
||||
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
|
||||
|
||||
// Setup push notifications
|
||||
showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
|
||||
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
|
||||
lifecycleScope.launch {
|
||||
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
|
||||
}
|
||||
} else {
|
||||
disableAllNotifications(this, accountManager)
|
||||
}
|
||||
|
||||
accountLocked = me.locked
|
||||
|
||||
updateProfiles()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import javax.inject.Inject
|
||||
|
|
@ -34,16 +33,12 @@ class SplashActivity : AppCompatActivity(), Injectable {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
/** delete old notification channels */
|
||||
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
|
||||
|
||||
/** Determine whether the user is currently logged in, and if so go ahead and load the
|
||||
* timeline. Otherwise, start the activity_login screen. */
|
||||
|
||||
val intent = if (accountManager.activeAccount != null) {
|
||||
Intent(this, MainActivity::class.java)
|
||||
} else {
|
||||
LoginActivity.getIntent(this, false)
|
||||
LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT)
|
||||
}
|
||||
startActivity(intent)
|
||||
finish()
|
||||
|
|
|
|||
|
|
@ -46,7 +46,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)
|
||||
else -> getString(R.string.title_list_timeline)
|
||||
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
|
||||
}
|
||||
|
||||
supportActionBar?.run {
|
||||
|
|
@ -73,6 +73,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
private const val EXTRA_KIND = "kind"
|
||||
private const val EXTRA_LIST_ID = "id"
|
||||
private const val EXTRA_LIST_TITLE = "title"
|
||||
private const val EXTRA_HASHTAG = "tag"
|
||||
|
||||
fun newFavouritesIntent(context: Context) =
|
||||
|
|
@ -85,10 +86,11 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
|
||||
}
|
||||
|
||||
fun newListIntent(context: Context, listId: String) =
|
||||
fun newListIntent(context: Context, listId: String, listTitle: String) =
|
||||
Intent(context, StatusListActivity::class.java).apply {
|
||||
putExtra(EXTRA_KIND, Kind.LIST.name)
|
||||
putExtra(EXTRA_LIST_ID, listId)
|
||||
putExtra(EXTRA_LIST_TITLE, listTitle)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ import androidx.appcompat.widget.AppCompatEditText
|
|||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.TransitionManager
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
|
|
@ -46,9 +48,9 @@ import com.keylesspalace.tusky.network.MastodonApi
|
|||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -253,10 +255,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
|
||||
private fun showSelectListDialog() {
|
||||
val adapter = ListSelectionAdapter(this)
|
||||
mastodonApi.getLists()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.getLists().fold(
|
||||
{ lists ->
|
||||
adapter.addAll(lists)
|
||||
},
|
||||
|
|
@ -264,6 +264,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
Log.e("TabPreferenceActivity", "failed to load lists", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_list_title)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
|
|||
|
||||
binding.username.text = account.fullName
|
||||
binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis)
|
||||
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
|
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
/* Copyright 2019 Conny Duck
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import androidx.paging.LoadState
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class NetworkStateViewHolder(
|
||||
private val binding: ItemNetworkStateBinding,
|
||||
private val retryCallback: () -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setUpWithNetworkState(state: LoadState) {
|
||||
binding.progressBar.visible(state == LoadState.Loading)
|
||||
binding.retryButton.visible(state is LoadState.Error)
|
||||
val msg = if (state is LoadState.Error) {
|
||||
state.error.message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
binding.errorMsg.visible(msg != null)
|
||||
binding.errorMsg.text = msg
|
||||
binding.retryButton.setOnClickListener {
|
||||
retryCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,10 +71,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
public static class Key {
|
||||
public static final String KEY_CREATED = "created";
|
||||
}
|
||||
|
||||
private TextView displayName;
|
||||
private TextView username;
|
||||
private ImageButton replyButton;
|
||||
private TextView replyCountLabel;
|
||||
private SparkButton reblogButton;
|
||||
private SparkButton favouriteButton;
|
||||
private SparkButton bookmarkButton;
|
||||
|
|
@ -123,6 +123,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
content = itemView.findViewById(R.id.status_content);
|
||||
avatar = itemView.findViewById(R.id.status_avatar);
|
||||
replyButton = itemView.findViewById(R.id.status_reply);
|
||||
replyCountLabel = itemView.findViewById(R.id.status_replies);
|
||||
reblogButton = itemView.findViewById(R.id.status_inset);
|
||||
favouriteButton = itemView.findViewById(R.id.status_favourite);
|
||||
bookmarkButton = itemView.findViewById(R.id.status_bookmark);
|
||||
|
|
@ -360,6 +361,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
}
|
||||
|
||||
private void setReplyCount(int repliesCount) {
|
||||
// This label only exists in the non-detailed view (to match the web ui)
|
||||
if (replyCountLabel != null) {
|
||||
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
|
||||
}
|
||||
}
|
||||
|
||||
private void setReblogged(boolean reblogged) {
|
||||
reblogButton.setChecked(reblogged);
|
||||
}
|
||||
|
|
@ -733,6 +741,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
setUsername(status.getUsername());
|
||||
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
|
||||
setIsReply(actionable.getInReplyToId() != null);
|
||||
setReplyCount(actionable.getRepliesCount());
|
||||
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||
actionable.getAccount().getBot(), statusDisplayOptions);
|
||||
setReblogged(actionable.getReblogged());
|
||||
|
|
@ -1037,6 +1046,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
actionable.getPoll() == null &&
|
||||
card != null &&
|
||||
!TextUtils.isEmpty(card.getUrl()) &&
|
||||
(!actionable.getSensitive() || status.isExpanded()) &&
|
||||
(!status.isCollapsible() || !status.isCollapsed())) {
|
||||
cardView.setVisibility(View.VISIBLE);
|
||||
cardTitle.setText(card.getTitle());
|
||||
|
|
|
|||
|
|
@ -103,20 +103,26 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
@NonNull final StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
// We never collapse statuses in the detail view
|
||||
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
|
||||
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
||||
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
if (payloads == null) {
|
||||
Status actionable = uncollapsedStatus.getActionable();
|
||||
|
||||
if (!statusDisplayOptions.hideStats()) {
|
||||
setReblogAndFavCount(status.getActionable().getReblogsCount(),
|
||||
status.getActionable().getFavouritesCount(), listener);
|
||||
setReblogAndFavCount(actionable.getReblogsCount(),
|
||||
actionable.getFavouritesCount(), listener);
|
||||
} else {
|
||||
hideQuantitativeStats();
|
||||
}
|
||||
|
||||
setApplication(status.getActionable().getApplication());
|
||||
setApplication(actionable.getApplication());
|
||||
|
||||
setStatusVisibility(status.getActionable().getVisibility());
|
||||
setStatusVisibility(actionable.getVisibility());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import javax.inject.Inject
|
|||
class CacheUpdater @Inject constructor(
|
||||
eventHub: EventHub,
|
||||
private val accountManager: AccountManager,
|
||||
private val appDatabase: AppDatabase,
|
||||
appDatabase: AppDatabase,
|
||||
gson: Gson
|
||||
) {
|
||||
|
||||
|
|
@ -44,8 +44,4 @@ class CacheUpdater @Inject constructor(
|
|||
fun stop() {
|
||||
this.disposable.dispose()
|
||||
}
|
||||
|
||||
suspend fun clearForUser(accountId: Long) {
|
||||
appDatabase.timelineDao().removeAll(accountId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ import com.keylesspalace.tusky.view.showMuteAccountDialog
|
|||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import java.text.NumberFormat
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
|
|
@ -413,6 +416,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
updateToolbar()
|
||||
updateMovedAccount()
|
||||
updateRemoteAccount()
|
||||
updateAccountJoinedDate()
|
||||
updateAccountStats()
|
||||
invalidateOptionsMenu()
|
||||
|
||||
|
|
@ -422,6 +426,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateAccountJoinedDate() {
|
||||
loadedAccount?.let { account ->
|
||||
try {
|
||||
binding.accountDateJoined.text = resources.getString(
|
||||
R.string.account_date_joined,
|
||||
SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(account.createdAt)
|
||||
)
|
||||
binding.accountDateJoined.visibility = View.VISIBLE
|
||||
} catch (e: ParseException) {
|
||||
binding.accountDateJoined.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load account's avatar and header image
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.net.Uri
|
||||
|
|
@ -56,6 +57,9 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.transition.TransitionManager
|
||||
import com.canhub.cropper.CropImage
|
||||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
|
@ -78,12 +82,12 @@ import com.keylesspalace.tusky.entity.Emoji
|
|||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.ComposeTokenizer
|
||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.afterTextChanged
|
||||
import com.keylesspalace.tusky.util.combineLiveData
|
||||
import com.keylesspalace.tusky.util.combineOptionalLiveData
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.highlightSpans
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
|
|
@ -152,6 +156,32 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
// Contract kicked off by editImageInQueue; expects viewModel.cropImageItemOld set
|
||||
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
|
||||
val uriNew = result.uriContent
|
||||
if (result.isSuccessful && uriNew != null) {
|
||||
viewModel.cropImageItemOld?.let { itemOld ->
|
||||
val size = getMediaSize(contentResolver, uriNew)
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.addMediaToQueue(
|
||||
itemOld.type,
|
||||
uriNew,
|
||||
size,
|
||||
itemOld.description,
|
||||
itemOld
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (result == CropImage.CancelledResult) {
|
||||
Log.w("ComposeActivity", "Edit image cancelled by user")
|
||||
} else {
|
||||
Log.w("ComposeActivity", "Edit image failed: " + result.error)
|
||||
displayTransientError(R.string.error_image_edit_failed)
|
||||
}
|
||||
viewModel.cropImageItemOld = null
|
||||
}
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
|
@ -186,6 +216,7 @@ class ComposeActivity :
|
|||
viewModel.updateDescription(item.localId, newDescription)
|
||||
}
|
||||
},
|
||||
onEditImage = this::editImageInQueue,
|
||||
onRemove = this::removeMediaFromQueue
|
||||
)
|
||||
binding.composeMediaPreviewBar.layoutManager =
|
||||
|
|
@ -307,7 +338,8 @@ class ComposeActivity :
|
|||
ComposeAutoCompleteAdapter(
|
||||
this,
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
)
|
||||
)
|
||||
binding.composeEditField.setTokenizer(ComposeTokenizer())
|
||||
|
|
@ -375,8 +407,13 @@ class ComposeActivity :
|
|||
enableButton(binding.composeAddMediaButton, active, active)
|
||||
enablePollButton(media.isNullOrEmpty())
|
||||
}.subscribe()
|
||||
viewModel.uploadError.observe {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
viewModel.uploadError.observe { throwable ->
|
||||
Log.w(TAG, "media upload failed", throwable)
|
||||
if (throwable is UploadServerError) {
|
||||
displayTransientError(throwable.errorMessage)
|
||||
} else {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
viewModel.setupComplete.observe {
|
||||
// Focus may have changed during view model setup, ensure initial focus is on the edit field
|
||||
|
|
@ -521,19 +558,23 @@ class ComposeActivity :
|
|||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG)
|
||||
private fun displayTransientError(errorMessage: String) {
|
||||
val bar = Snackbar.make(binding.activityCompose, errorMessage, Snackbar.LENGTH_LONG)
|
||||
// necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.setAnchorView(R.id.composeBottomBar)
|
||||
bar.show()
|
||||
}
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
displayTransientError(getString(stringId))
|
||||
}
|
||||
|
||||
private fun toggleHideMedia() {
|
||||
this.viewModel.toggleMarkSensitive()
|
||||
}
|
||||
|
||||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
if (viewModel.media.value.isNullOrEmpty()) {
|
||||
if (viewModel.media.value.isEmpty()) {
|
||||
binding.composeHideMediaButton.hide()
|
||||
} else {
|
||||
binding.composeHideMediaButton.show()
|
||||
|
|
@ -867,6 +908,26 @@ class ComposeActivity :
|
|||
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun editImageInQueue(item: QueuedMedia) {
|
||||
// If input image is lossless, output image should be lossless.
|
||||
// Currently the only supported lossless format is png.
|
||||
val mimeType: String? = contentResolver.getType(item.uri)
|
||||
val isPng: Boolean = mimeType != null && mimeType.endsWith("/png")
|
||||
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)
|
||||
|
||||
viewModel.cropImageItemOld = item
|
||||
|
||||
cropImage.launch(
|
||||
options(uri = item.uri) {
|
||||
setOutputUri(uriNew)
|
||||
setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,320 +0,0 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.Filter;
|
||||
import android.widget.Filterable;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
import com.keylesspalace.tusky.entity.HashTag;
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Created by charlag on 12/11/17.
|
||||
*/
|
||||
|
||||
public class ComposeAutoCompleteAdapter extends BaseAdapter
|
||||
implements Filterable {
|
||||
private static final int ACCOUNT_VIEW_TYPE = 1;
|
||||
private static final int HASHTAG_VIEW_TYPE = 2;
|
||||
private static final int EMOJI_VIEW_TYPE = 3;
|
||||
private static final int SEPARATOR_VIEW_TYPE = 0;
|
||||
|
||||
private final ArrayList<AutocompleteResult> resultList;
|
||||
private final AutocompletionProvider autocompletionProvider;
|
||||
private final boolean animateAvatar;
|
||||
private final boolean animateEmojis;
|
||||
|
||||
public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) {
|
||||
super();
|
||||
resultList = new ArrayList<>();
|
||||
this.autocompletionProvider = autocompletionProvider;
|
||||
this.animateAvatar = animateAvatar;
|
||||
this.animateEmojis = animateEmojis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return resultList.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AutocompleteResult getItem(int index) {
|
||||
return resultList.get(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Filter getFilter() {
|
||||
return new Filter() {
|
||||
@Override
|
||||
public CharSequence convertResultToString(Object resultValue) {
|
||||
if (resultValue instanceof AccountResult) {
|
||||
return formatUsername(((AccountResult) resultValue));
|
||||
} else if (resultValue instanceof HashtagResult) {
|
||||
return formatHashtag((HashtagResult) resultValue);
|
||||
} else if (resultValue instanceof EmojiResult) {
|
||||
return formatEmoji((EmojiResult) resultValue);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// This method is invoked in a worker thread.
|
||||
@Override
|
||||
protected FilterResults performFiltering(CharSequence constraint) {
|
||||
FilterResults filterResults = new FilterResults();
|
||||
if (constraint != null) {
|
||||
List<AutocompleteResult> results =
|
||||
autocompletionProvider.search(constraint.toString());
|
||||
filterResults.values = results;
|
||||
filterResults.count = results.size();
|
||||
}
|
||||
return filterResults;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
protected void publishResults(CharSequence constraint, FilterResults results) {
|
||||
if (results != null && results.count > 0) {
|
||||
resultList.clear();
|
||||
resultList.addAll((List<AutocompleteResult>) results.values);
|
||||
notifyDataSetChanged();
|
||||
} else {
|
||||
notifyDataSetInvalidated();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
||||
View view = convertView;
|
||||
final Context context = parent.getContext();
|
||||
|
||||
switch (getItemViewType(position)) {
|
||||
case ACCOUNT_VIEW_TYPE:
|
||||
AccountViewHolder accountViewHolder;
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_account, parent, false);
|
||||
}
|
||||
if (view.getTag() == null) {
|
||||
view.setTag(new AccountViewHolder(view));
|
||||
}
|
||||
accountViewHolder = (AccountViewHolder) view.getTag();
|
||||
|
||||
AccountResult accountResult = ((AccountResult) getItem(position));
|
||||
if (accountResult != null) {
|
||||
TimelineAccount account = accountResult.account;
|
||||
String formattedUsername = context.getString(
|
||||
R.string.post_username_format,
|
||||
account.getUsername()
|
||||
);
|
||||
accountViewHolder.username.setText(formattedUsername);
|
||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
|
||||
account.getEmojis(), accountViewHolder.displayName, animateEmojis);
|
||||
accountViewHolder.displayName.setText(emojifiedName);
|
||||
|
||||
int avatarRadius = accountViewHolder.avatar.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(
|
||||
account.getAvatar(),
|
||||
accountViewHolder.avatar,
|
||||
avatarRadius,
|
||||
animateAvatar
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case HASHTAG_VIEW_TYPE:
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_hashtag, parent, false);
|
||||
}
|
||||
|
||||
HashtagResult result = (HashtagResult) getItem(position);
|
||||
if (result != null) {
|
||||
((TextView) view).setText(formatHashtag(result));
|
||||
}
|
||||
break;
|
||||
|
||||
case EMOJI_VIEW_TYPE:
|
||||
EmojiViewHolder emojiViewHolder;
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_emoji, parent, false);
|
||||
}
|
||||
if (view.getTag() == null) {
|
||||
view.setTag(new EmojiViewHolder(view));
|
||||
}
|
||||
emojiViewHolder = (EmojiViewHolder) view.getTag();
|
||||
|
||||
EmojiResult emojiResult = ((EmojiResult) getItem(position));
|
||||
if (emojiResult != null) {
|
||||
Emoji emoji = emojiResult.emoji;
|
||||
String formattedShortcode = context.getString(
|
||||
R.string.emoji_shortcode_format,
|
||||
emoji.getShortcode()
|
||||
);
|
||||
emojiViewHolder.shortcode.setText(formattedShortcode);
|
||||
Glide.with(emojiViewHolder.preview)
|
||||
.load(emoji.getUrl())
|
||||
.into(emojiViewHolder.preview);
|
||||
}
|
||||
break;
|
||||
|
||||
case SEPARATOR_VIEW_TYPE:
|
||||
if (convertView == null) {
|
||||
view = ((LayoutInflater) context
|
||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
||||
.inflate(R.layout.item_autocomplete_divider, parent, false);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError("unknown view type");
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
private static String formatUsername(AccountResult result) {
|
||||
return String.format("@%s", result.account.getUsername());
|
||||
}
|
||||
|
||||
private static String formatHashtag(HashtagResult result) {
|
||||
return String.format("#%s", result.hashtag);
|
||||
}
|
||||
|
||||
private static String formatEmoji(EmojiResult result) {
|
||||
return String.format(":%s:", result.emoji.getShortcode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getViewTypeCount() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
AutocompleteResult item = getItem(position);
|
||||
|
||||
if (item instanceof AccountResult) {
|
||||
return ACCOUNT_VIEW_TYPE;
|
||||
} else if (item instanceof HashtagResult) {
|
||||
return HASHTAG_VIEW_TYPE;
|
||||
} else if (item instanceof EmojiResult) {
|
||||
return EMOJI_VIEW_TYPE;
|
||||
} else {
|
||||
return SEPARATOR_VIEW_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areAllItemsEnabled() {
|
||||
// there may be separators
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled(int position) {
|
||||
return !(getItem(position) instanceof ResultSeparator);
|
||||
}
|
||||
|
||||
public abstract static class AutocompleteResult {
|
||||
AutocompleteResult() {
|
||||
}
|
||||
}
|
||||
|
||||
public final static class AccountResult extends AutocompleteResult {
|
||||
private final TimelineAccount account;
|
||||
|
||||
public AccountResult(TimelineAccount account) {
|
||||
this.account = account;
|
||||
}
|
||||
}
|
||||
|
||||
public final static class HashtagResult extends AutocompleteResult {
|
||||
private final String hashtag;
|
||||
|
||||
public HashtagResult(HashTag hashtag) {
|
||||
this.hashtag = hashtag.getName();
|
||||
}
|
||||
}
|
||||
|
||||
public final static class EmojiResult extends AutocompleteResult {
|
||||
private final Emoji emoji;
|
||||
|
||||
public EmojiResult(Emoji emoji) {
|
||||
this.emoji = emoji;
|
||||
}
|
||||
}
|
||||
|
||||
public final static class ResultSeparator extends AutocompleteResult {}
|
||||
|
||||
public interface AutocompletionProvider {
|
||||
List<AutocompleteResult> search(String mention);
|
||||
}
|
||||
|
||||
private class AccountViewHolder {
|
||||
final TextView username;
|
||||
final TextView displayName;
|
||||
final ImageView avatar;
|
||||
|
||||
private AccountViewHolder(View view) {
|
||||
username = view.findViewById(R.id.username);
|
||||
displayName = view.findViewById(R.id.display_name);
|
||||
avatar = view.findViewById(R.id.avatar);
|
||||
}
|
||||
}
|
||||
|
||||
private class EmojiViewHolder {
|
||||
final TextView shortcode;
|
||||
final ImageView preview;
|
||||
|
||||
private EmojiViewHolder(View view) {
|
||||
shortcode = view.findViewById(R.id.shortcode);
|
||||
preview = view.findViewById(R.id.preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
/* Copyright 2022 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.Filter
|
||||
import android.widget.Filterable
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class ComposeAutoCompleteAdapter(
|
||||
private val autocompletionProvider: AutocompletionProvider,
|
||||
private val animateAvatar: Boolean,
|
||||
private val animateEmojis: Boolean,
|
||||
private val showBotBadge: Boolean
|
||||
) : BaseAdapter(), Filterable {
|
||||
|
||||
private var resultList: List<AutocompleteResult> = emptyList()
|
||||
|
||||
override fun getCount() = resultList.size
|
||||
|
||||
override fun getItem(index: Int): AutocompleteResult {
|
||||
return resultList[index]
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getFilter(): Filter {
|
||||
return object : Filter() {
|
||||
|
||||
override fun convertResultToString(resultValue: Any): CharSequence {
|
||||
return when (resultValue) {
|
||||
is AutocompleteResult.AccountResult -> formatUsername(resultValue)
|
||||
is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
|
||||
is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val filterResults = FilterResults()
|
||||
if (constraint != null) {
|
||||
val results = autocompletionProvider.search(constraint.toString())
|
||||
filterResults.values = results
|
||||
filterResults.count = results.size
|
||||
}
|
||||
return filterResults
|
||||
}
|
||||
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||
if (results.count > 0) {
|
||||
resultList = results.values as List<AutocompleteResult>
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
notifyDataSetInvalidated()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val itemViewType = getItemViewType(position)
|
||||
val context = parent.context
|
||||
|
||||
val view: View = convertView ?: run {
|
||||
val layoutInflater = LayoutInflater.from(context)
|
||||
val binding = when (itemViewType) {
|
||||
ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater)
|
||||
HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater)
|
||||
EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater)
|
||||
else -> throw AssertionError("unknown view type")
|
||||
}
|
||||
binding.root.tag = binding
|
||||
binding.root
|
||||
}
|
||||
|
||||
when (val binding = view.tag) {
|
||||
is ItemAutocompleteAccountBinding -> {
|
||||
val accountResult = getItem(position) as AutocompleteResult.AccountResult
|
||||
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)
|
||||
loadAvatar(
|
||||
account.avatar,
|
||||
binding.avatar,
|
||||
avatarRadius,
|
||||
animateAvatar
|
||||
)
|
||||
binding.avatarBadge.visible(showBotBadge && account.bot)
|
||||
}
|
||||
is ItemAutocompleteHashtagBinding -> {
|
||||
val result = getItem(position) as AutocompleteResult.HashtagResult
|
||||
binding.root.text = formatHashtag(result)
|
||||
}
|
||||
is ItemAutocompleteEmojiBinding -> {
|
||||
val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
|
||||
val (shortcode, url) = emojiResult.emoji
|
||||
binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode)
|
||||
Glide.with(binding.preview)
|
||||
.load(url)
|
||||
.into(binding.preview)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getViewTypeCount() = 3
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (getItem(position)) {
|
||||
is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE
|
||||
is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE
|
||||
is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE
|
||||
}
|
||||
}
|
||||
|
||||
sealed class AutocompleteResult {
|
||||
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
|
||||
|
||||
class HashtagResult(val hashtag: String) : AutocompleteResult()
|
||||
|
||||
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
|
||||
}
|
||||
|
||||
interface AutocompletionProvider {
|
||||
fun search(token: String): List<AutocompleteResult>
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACCOUNT_VIEW_TYPE = 0
|
||||
private const val HASHTAG_VIEW_TYPE = 1
|
||||
private const val EMOJI_VIEW_TYPE = 2
|
||||
|
||||
private fun formatUsername(result: AutocompleteResult.AccountResult): String {
|
||||
return String.format("@%s", result.account.username)
|
||||
}
|
||||
|
||||
private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
|
||||
return String.format("#%s", result.hashtag)
|
||||
}
|
||||
|
||||
private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
|
||||
return String.format(":%s:", result.emoji.shortcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
|
|
@ -23,7 +23,9 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
|
|
@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
class ComposeViewModel @Inject constructor(
|
||||
|
|
@ -94,6 +95,9 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
emoji.postValue(instanceInfoRepo.getEmojis())
|
||||
|
|
@ -121,13 +125,16 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun addMediaToQueue(
|
||||
suspend fun addMediaToQueue(
|
||||
type: QueuedMedia.Type,
|
||||
uri: Uri,
|
||||
mediaSize: Long,
|
||||
description: String? = null
|
||||
description: String? = null,
|
||||
replaceItem: QueuedMedia? = null
|
||||
): QueuedMedia {
|
||||
val mediaItem = media.updateAndGet { mediaValue ->
|
||||
var stashMediaItem: QueuedMedia? = null
|
||||
|
||||
media.updateAndGet { mediaValue ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
|
||||
uri = uri,
|
||||
|
|
@ -135,8 +142,19 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaSize = mediaSize,
|
||||
description = description
|
||||
)
|
||||
mediaValue + mediaItem
|
||||
}.last()
|
||||
stashMediaItem = mediaItem
|
||||
|
||||
if (replaceItem != null) {
|
||||
mediaToJob[replaceItem.localId]?.cancel()
|
||||
mediaValue.map {
|
||||
if (it.localId == replaceItem.localId) mediaItem else it
|
||||
}
|
||||
} else { // Append
|
||||
mediaValue + mediaItem
|
||||
}
|
||||
}
|
||||
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
|
||||
|
||||
mediaToJob[mediaItem.localId] = viewModelScope.launch {
|
||||
mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
|
|
@ -201,7 +219,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val contentWarningChanged = showContentWarning.value!! &&
|
||||
!contentWarning.isNullOrEmpty() &&
|
||||
!startingContentWarning.startsWith(contentWarning.toString())
|
||||
val mediaChanged = !media.value.isNullOrEmpty()
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
|
||||
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged
|
||||
|
|
@ -330,48 +348,37 @@ class ComposeViewModel @Inject constructor(
|
|||
return true
|
||||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return try {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.blockingGet()
|
||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
return api.searchAccountsSync(query = token.substring(1), limit = 10)
|
||||
.fold({ accounts ->
|
||||
accounts.map { AutocompleteResult.AccountResult(it) }
|
||||
}, { e ->
|
||||
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||
emptyList()
|
||||
})
|
||||
}
|
||||
'#' -> {
|
||||
return try {
|
||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.blockingGet()
|
||||
.hashtags
|
||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
|
||||
}, { e ->
|
||||
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||
emptyList()
|
||||
})
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
val incomplete = token.substring(1)
|
||||
|
||||
val incomplete = token.substring(1).lowercase(Locale.ROOT)
|
||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
for (emoji in emojiList) {
|
||||
val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
|
||||
if (shortcode.startsWith(incomplete)) {
|
||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
}
|
||||
return emojiList.filter { emoji ->
|
||||
emoji.shortcode.contains(incomplete, ignoreCase = true)
|
||||
}.sortedBy { emoji ->
|
||||
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
|
||||
}.map { emoji ->
|
||||
AutocompleteResult.EmojiResult(emoji)
|
||||
}
|
||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
||||
}
|
||||
results.addAll(resultsInside)
|
||||
return results
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
|
|||
class MediaPreviewAdapter(
|
||||
context: Context,
|
||||
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
|
||||
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
|
||||
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
|
||||
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||
|
||||
|
|
@ -43,12 +44,16 @@ class MediaPreviewAdapter(
|
|||
val item = differ.currentList[position]
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val addCaptionId = 1
|
||||
val removeId = 2
|
||||
val editImageId = 2
|
||||
val removeId = 3
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE)
|
||||
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
addCaptionId -> onAddCaption(item)
|
||||
editImageId -> onEditImage(item)
|
||||
removeId -> onRemove(item)
|
||||
}
|
||||
true
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import android.util.Log
|
|||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
|
|
@ -31,6 +32,7 @@ import com.keylesspalace.tusky.network.ProgressRequestBody
|
|||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -54,14 +56,14 @@ sealed class UploadEvent {
|
|||
data class FinishedEvent(val mediaId: String) : UploadEvent()
|
||||
}
|
||||
|
||||
fun createNewImageFile(context: Context): File {
|
||||
fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
|
||||
// Create an image file name
|
||||
val randomId = randomAlphanumericString(12)
|
||||
val imageFileName = "Tusky_${randomId}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(
|
||||
imageFileName, /* prefix */
|
||||
".jpg", /* suffix */
|
||||
suffix, /* suffix */
|
||||
storageDir /* directory */
|
||||
)
|
||||
}
|
||||
|
|
@ -72,6 +74,7 @@ class AudioSizeException : Exception()
|
|||
class VideoSizeException : Exception()
|
||||
class MediaTypeException : Exception()
|
||||
class CouldNotOpenFileException : Exception()
|
||||
class UploadServerError(val errorMessage: String) : Exception()
|
||||
|
||||
class MediaUploader @Inject constructor(
|
||||
private val context: Context,
|
||||
|
|
@ -222,8 +225,16 @@ class MediaUploader @Inject constructor(
|
|||
null
|
||||
}
|
||||
|
||||
val result = mediaUploadApi.uploadMedia(body, description).getOrThrow()
|
||||
send(UploadEvent.FinishedEvent(result.id))
|
||||
mediaUploadApi.uploadMedia(body, description).fold({ result ->
|
||||
send(UploadEvent.FinishedEvent(result.id))
|
||||
}, { throwable ->
|
||||
val errorMessage = throwable.getServerErrorMessage()
|
||||
if (errorMessage == null) {
|
||||
throw throwable
|
||||
} else {
|
||||
throw UploadServerError(errorMessage)
|
||||
}
|
||||
})
|
||||
awaitClose()
|
||||
}
|
||||
}
|
||||
|
|
@ -240,7 +251,7 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "MediaUploaderImpl"
|
||||
private const val TAG = "MediaUploader"
|
||||
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
|
||||
|
|
|
|||
|
|
@ -20,21 +20,40 @@ import android.view.ViewGroup
|
|||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
|
||||
class ConversationAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private var statusDisplayOptions: StatusDisplayOptions,
|
||||
private val listener: StatusActionListener
|
||||
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
|
||||
|
||||
var mediaPreviewEnabled: Boolean
|
||||
get() = statusDisplayOptions.mediaPreviewEnabled
|
||||
set(mediaPreviewEnabled) {
|
||||
statusDisplayOptions = statusDisplayOptions.copy(
|
||||
mediaPreviewEnabled = mediaPreviewEnabled
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
|
||||
return ConversationViewHolder(view, statusDisplayOptions, listener)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
|
||||
holder.setupWithConversation(getItem(position))
|
||||
onBindViewHolder(holder, position, emptyList())
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: ConversationViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
getItem(position)?.let { conversationViewData ->
|
||||
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -44,7 +63,17 @@ class ConversationAdapter(
|
|||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
|
||||
return oldItem == newItem
|
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
|
||||
return if (oldItem == newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
} else {
|
||||
// If items are different - update the whole view holder
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import java.util.Date
|
|||
data class ConversationEntity(
|
||||
val accountId: Long,
|
||||
val id: String,
|
||||
val order: Int,
|
||||
val accounts: List<ConversationAccountEntity>,
|
||||
val unread: Boolean,
|
||||
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
|
||||
|
|
@ -41,6 +42,7 @@ data class ConversationEntity(
|
|||
fun toViewData(): ConversationViewData {
|
||||
return ConversationViewData(
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts,
|
||||
unread = unread,
|
||||
lastStatus = lastStatus.toViewData()
|
||||
|
|
@ -50,6 +52,7 @@ data class ConversationEntity(
|
|||
|
||||
data class ConversationAccountEntity(
|
||||
val id: String,
|
||||
val localUsername: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val avatar: String,
|
||||
|
|
@ -58,12 +61,12 @@ data class ConversationAccountEntity(
|
|||
fun toAccount(): TimelineAccount {
|
||||
return TimelineAccount(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
url = "",
|
||||
avatar = avatar,
|
||||
emojis = emojis,
|
||||
localUsername = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +82,7 @@ data class ConversationStatusEntity(
|
|||
val createdAt: Date,
|
||||
val emojis: List<Emoji>,
|
||||
val favouritesCount: Int,
|
||||
val repliesCount: Int,
|
||||
val favourited: Boolean,
|
||||
val bookmarked: Boolean,
|
||||
val sensitive: Boolean,
|
||||
|
|
@ -107,6 +111,7 @@ data class ConversationStatusEntity(
|
|||
emojis = emojis,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = favouritesCount,
|
||||
repliesCount = repliesCount,
|
||||
reblogged = false,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
|
|
@ -132,6 +137,7 @@ data class ConversationStatusEntity(
|
|||
fun TimelineAccount.toEntity() =
|
||||
ConversationAccountEntity(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
|
|
@ -149,6 +155,7 @@ fun Status.toEntity() =
|
|||
createdAt = createdAt,
|
||||
emojis = emojis,
|
||||
favouritesCount = favouritesCount,
|
||||
repliesCount = repliesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = sensitive,
|
||||
|
|
@ -163,10 +170,11 @@ fun Status.toEntity() =
|
|||
poll = poll
|
||||
)
|
||||
|
||||
fun Conversation.toEntity(accountId: Long) =
|
||||
fun Conversation.toEntity(accountId: Long, order: Int) =
|
||||
ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity()
|
||||
|
|
|
|||
|
|
@ -19,22 +19,35 @@ import android.view.LayoutInflater
|
|||
import android.view.ViewGroup
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStateAdapter
|
||||
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
class ConversationLoadStateAdapter(
|
||||
private val retryCallback: () -> Unit
|
||||
) : LoadStateAdapter<NetworkStateViewHolder>() {
|
||||
) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
|
||||
|
||||
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) {
|
||||
holder.setUpWithNetworkState(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)
|
||||
val msg = if (loadState is LoadState.Error) {
|
||||
loadState.error.message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
binding.errorMsg.visible(msg != null)
|
||||
binding.errorMsg.text = msg
|
||||
binding.retryButton.setOnClickListener {
|
||||
retryCallback()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loadState: LoadState
|
||||
): NetworkStateViewHolder {
|
||||
): BindingHolder<ItemNetworkStateBinding> {
|
||||
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return NetworkStateViewHolder(binding, retryCallback)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
|
|||
|
||||
data class ConversationViewData(
|
||||
val id: String,
|
||||
val order: Int,
|
||||
val accounts: List<ConversationAccountEntity>,
|
||||
val unread: Boolean,
|
||||
val lastStatus: StatusViewData.Concrete
|
||||
|
|
@ -37,6 +38,7 @@ data class ConversationViewData(
|
|||
return ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts,
|
||||
unread = unread,
|
||||
lastStatus = lastStatus.toConversationStatusEntity(
|
||||
|
|
@ -71,6 +73,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
|
|||
createdAt = status.createdAt,
|
||||
emojis = status.emojis,
|
||||
favouritesCount = status.favouritesCount,
|
||||
repliesCount = status.repliesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import android.widget.Button;
|
|||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
|
|
@ -43,12 +45,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
|
||||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||
|
||||
private TextView conversationNameTextView;
|
||||
private Button contentCollapseButton;
|
||||
private ImageView[] avatars;
|
||||
private final TextView conversationNameTextView;
|
||||
private final Button contentCollapseButton;
|
||||
private final ImageView[] avatars;
|
||||
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private StatusActionListener listener;
|
||||
private final StatusDisplayOptions statusDisplayOptions;
|
||||
private final StatusActionListener listener;
|
||||
|
||||
ConversationViewHolder(View itemView,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
|
|
@ -64,7 +66,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
|
||||
this.listener = listener;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -72,52 +73,67 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
|
||||
}
|
||||
|
||||
void setupWithConversation(ConversationViewData conversation) {
|
||||
void setupWithConversation(
|
||||
@NonNull ConversationViewData conversation,
|
||||
@Nullable Object payloads
|
||||
) {
|
||||
|
||||
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
|
||||
Status status = statusViewData.getStatus();
|
||||
TimelineAccount account = status.getAccount();
|
||||
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||
if (payloads == null) {
|
||||
TimelineAccount account = status.getAccount();
|
||||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
setIsReply(status.getInReplyToId() != null);
|
||||
setFavourited(status.getFavourited());
|
||||
setBookmarked(status.getBookmarked());
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = status.getSensitive();
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
|
||||
statusDisplayOptions.useBlurhash());
|
||||
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
|
||||
|
||||
if (attachments.size() == 0) {
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
setIsReply(status.getInReplyToId() != null);
|
||||
setFavourited(status.getFavourited());
|
||||
setBookmarked(status.getBookmarked());
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = status.getSensitive();
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
|
||||
statusDisplayOptions.useBlurhash());
|
||||
|
||||
if (attachments.size() == 0) {
|
||||
hideSensitiveMediaWarning();
|
||||
}
|
||||
// Hide the unused label.
|
||||
for (TextView mediaLabel : mediaLabels) {
|
||||
mediaLabel.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
|
||||
// Hide all unused views.
|
||||
mediaPreviews[0].setVisibility(View.GONE);
|
||||
mediaPreviews[1].setVisibility(View.GONE);
|
||||
mediaPreviews[2].setVisibility(View.GONE);
|
||||
mediaPreviews[3].setVisibility(View.GONE);
|
||||
hideSensitiveMediaWarning();
|
||||
}
|
||||
// Hide the unused label.
|
||||
for (TextView mediaLabel : mediaLabels) {
|
||||
mediaLabel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
|
||||
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
||||
status.getMentions(), status.getTags(), status.getEmojis(),
|
||||
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
||||
|
||||
setConversationName(conversation.getAccounts());
|
||||
|
||||
setAvatars(conversation.getAccounts());
|
||||
} else {
|
||||
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
|
||||
// Hide all unused views.
|
||||
mediaPreviews[0].setVisibility(View.GONE);
|
||||
mediaPreviews[1].setVisibility(View.GONE);
|
||||
mediaPreviews[2].setVisibility(View.GONE);
|
||||
mediaPreviews[3].setVisibility(View.GONE);
|
||||
hideSensitiveMediaWarning();
|
||||
if (payloads instanceof List) {
|
||||
for (Object item : (List<?>) payloads) {
|
||||
if (Key.KEY_CREATED.equals(item)) {
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
|
||||
statusDisplayOptions);
|
||||
|
||||
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
|
||||
status.getMentions(), status.getTags(), status.getEmojis(),
|
||||
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
|
||||
|
||||
setConversationName(conversation.getAccounts());
|
||||
|
||||
setAvatars(conversation.getAccounts());
|
||||
}
|
||||
|
||||
private void setConversationName(List<ConversationAccountEntity> accounts) {
|
||||
|
|
@ -169,4 +185,4 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
content.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,20 +22,27 @@ import android.view.ViewGroup
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadState
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import autodispose2.androidx.lifecycle.autoDispose
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
|
|
@ -44,29 +51,31 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||
|
||||
private lateinit var adapter: ConversationAdapter
|
||||
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
|
||||
|
||||
private var layoutManager: LinearLayoutManager? = null
|
||||
|
||||
private var initialRefreshDone: Boolean = false
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_timeline, container, false)
|
||||
|
|
@ -89,56 +98,106 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
)
|
||||
|
||||
adapter = ConversationAdapter(statusDisplayOptions, this)
|
||||
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
layoutManager = LinearLayoutManager(view.context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.progressBar.hide()
|
||||
binding.statusView.hide()
|
||||
setupRecyclerView()
|
||||
|
||||
initSwipeToRefresh()
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
when (loadState.refresh) {
|
||||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
}
|
||||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||
}
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||
binding.recyclerView.post {
|
||||
if (getView() != null) {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
if (composeButton != null) {
|
||||
if (hideFab) {
|
||||
if (dy > 0 && composeButton.isShown) {
|
||||
composeButton.hide() // hides the button if we're scrolling down
|
||||
} else if (dy < 0 && !composeButton.isShown) {
|
||||
composeButton.show() // shows it if we are scrolling up
|
||||
}
|
||||
} else if (!composeButton.isShown) {
|
||||
composeButton.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.conversationFlow.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.addLoadStateListener { loadStates ->
|
||||
|
||||
loadStates.refresh.let { refreshState ->
|
||||
if (refreshState is LoadState.Error) {
|
||||
binding.statusView.show()
|
||||
if (refreshState.error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
adapter.refresh()
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
adapter.refresh()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.statusView.hide()
|
||||
}
|
||||
|
||||
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
|
||||
|
||||
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
|
||||
// jump to top after the initial refresh finished
|
||||
binding.recyclerView.scrollToPosition(0)
|
||||
initialRefreshDone = true
|
||||
}
|
||||
|
||||
if (refreshState != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
lifecycleScope.launchWhenResumed {
|
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||
while (!useAbsoluteTime) {
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
|
||||
delay(1.toDuration(DurationUnit.MINUTES))
|
||||
}
|
||||
}
|
||||
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe { event ->
|
||||
if (event is PreferenceChangedEvent) {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
|
||||
}
|
||||
|
||||
private fun initSwipeToRefresh() {
|
||||
|
|
@ -201,7 +260,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun onOpenReblog(position: Int) {
|
||||
// there are no reblogs in search results
|
||||
// there are no reblogs in conversations
|
||||
}
|
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
||||
|
|
@ -246,6 +305,19 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
viewModel.voteInPoll(choices, conversation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteConversation(conversation: ConversationViewData) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.dialog_delete_conversation_warning)
|
||||
|
|
@ -256,20 +328,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
.show()
|
||||
}
|
||||
|
||||
private fun jumpToTop() {
|
||||
if (isAdded) {
|
||||
layoutManager?.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReselect() {
|
||||
jumpToTop()
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
|
||||
adapter.peek(position)?.let { conversation ->
|
||||
viewModel.voteInPoll(choices, conversation)
|
||||
private fun onPreferenceChanged(key: String) {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
when (key) {
|
||||
PrefKeys.FAB_HIDE -> {
|
||||
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
|
||||
}
|
||||
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
|
||||
val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
|
||||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||
if (enabled != oldMediaPreviewEnabled) {
|
||||
adapter.mediaPreviewEnabled = enabled
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import androidx.paging.ExperimentalPagingApi
|
|||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.withTransaction
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import retrofit2.HttpException
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class ConversationsRemoteMediator(
|
||||
|
|
@ -14,38 +17,53 @@ class ConversationsRemoteMediator(
|
|||
private val db: AppDatabase
|
||||
) : RemoteMediator<Int, ConversationEntity>() {
|
||||
|
||||
private var nextKey: String? = null
|
||||
|
||||
private var order: Int = 0
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, ConversationEntity>
|
||||
): MediatorResult {
|
||||
|
||||
if (loadType == LoadType.PREPEND) {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
nextKey = null
|
||||
order = 0
|
||||
}
|
||||
|
||||
try {
|
||||
val conversationsResult = when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
api.getConversations(limit = state.config.initialLoadSize)
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
LoadType.APPEND -> {
|
||||
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id
|
||||
api.getConversations(maxId = maxId, limit = state.config.pageSize)
|
||||
}
|
||||
val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
|
||||
|
||||
val conversations = conversationsResponse.body()
|
||||
if (!conversationsResponse.isSuccessful || conversations == null) {
|
||||
return MediatorResult.Error(HttpException(conversationsResponse))
|
||||
}
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
db.withTransaction {
|
||||
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
}
|
||||
|
||||
val linkHeader = conversationsResponse.headers()["Link"]
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
|
||||
|
||||
db.conversationDao().insert(
|
||||
conversations
|
||||
.filterNot { it.lastStatus == null }
|
||||
.map {
|
||||
it.toEntity(accountId, order++)
|
||||
}
|
||||
)
|
||||
}
|
||||
db.conversationDao().insert(
|
||||
conversationsResult
|
||||
.filterNot { it.lastStatus == null }
|
||||
.map { it.toEntity(accountId) }
|
||||
)
|
||||
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
|
||||
return MediatorResult.Success(endOfPaginationReached = nextKey == null)
|
||||
} catch (e: Exception) {
|
||||
return MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.conversation
|
||||
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConversationsRepository @Inject constructor(
|
||||
val mastodonApi: MastodonApi,
|
||||
val db: AppDatabase
|
||||
) {
|
||||
|
||||
fun deleteCacheForAccount(accountId: Long) {
|
||||
Single.fromCallable {
|
||||
db.conversationDao().deleteForAccount(accountId)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ import androidx.paging.map
|
|||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
|
|
@ -41,7 +41,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val conversationFlow = Pager(
|
||||
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20),
|
||||
config = PagingConfig(pageSize = 30),
|
||||
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
|
||||
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
package com.keylesspalace.tusky.components.instanceinfo
|
||||
|
||||
import android.util.Log
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.EmojisEntity
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import android.widget.TextView
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
|
|
@ -34,6 +35,7 @@ import com.keylesspalace.tusky.MainActivity
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.AccessToken
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.getNonNullString
|
||||
import com.keylesspalace.tusky.util.rickRoll
|
||||
|
|
@ -93,12 +95,17 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
|
||||
if (savedInstanceState == null &&
|
||||
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
|
||||
!isAdditionalLogin()
|
||||
!isAdditionalLogin() && !isAccountMigration()
|
||||
) {
|
||||
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
|
||||
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
|
||||
}
|
||||
|
||||
if (isAccountMigration()) {
|
||||
binding.domainEditText.setText(accountManager.activeAccount!!.domain)
|
||||
binding.domainEditText.isEnabled = false
|
||||
}
|
||||
|
||||
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
|
||||
Glide.with(binding.loginLogo)
|
||||
.load(BuildConfig.CUSTOM_LOGO_URL)
|
||||
|
|
@ -122,7 +129,7 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
textView?.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
if (isAdditionalLogin()) {
|
||||
if (isAdditionalLogin() || isAccountMigration()) {
|
||||
setSupportActionBar(binding.toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
|
|
@ -137,7 +144,7 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
if (isAdditionalLogin()) {
|
||||
if (isAdditionalLogin() || isAccountMigration()) {
|
||||
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
|
||||
}
|
||||
}
|
||||
|
|
@ -244,26 +251,50 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
|
||||
).fold(
|
||||
{ accessToken ->
|
||||
accountManager.addAccount(accessToken.accessToken, domain)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
finish()
|
||||
overridePendingTransition(R.anim.explode, R.anim.explode)
|
||||
fetchAccountDetails(accessToken, domain, clientId, clientSecret)
|
||||
},
|
||||
{ e ->
|
||||
setLoading(false)
|
||||
binding.domainTextInputLayout.error =
|
||||
getString(R.string.error_retrieving_oauth_token)
|
||||
Log.e(
|
||||
TAG,
|
||||
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
|
||||
)
|
||||
Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchAccountDetails(
|
||||
accessToken: AccessToken,
|
||||
domain: String,
|
||||
clientId: String,
|
||||
clientSecret: String
|
||||
) {
|
||||
|
||||
mastodonApi.accountVerifyCredentials(
|
||||
domain = domain,
|
||||
auth = "Bearer ${accessToken.accessToken}"
|
||||
).fold({ newAccount ->
|
||||
accountManager.addAccount(
|
||||
accessToken = accessToken.accessToken,
|
||||
domain = domain,
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
oauthScopes = OAUTH_SCOPES,
|
||||
newAccount = newAccount
|
||||
)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
finish()
|
||||
overridePendingTransition(R.anim.explode, R.anim.explode)
|
||||
}, { e ->
|
||||
setLoading(false)
|
||||
binding.domainTextInputLayout.error =
|
||||
getString(R.string.error_loading_account_details)
|
||||
Log.e(TAG, getString(R.string.error_loading_account_details), e)
|
||||
})
|
||||
}
|
||||
|
||||
private fun setLoading(loadingState: Boolean) {
|
||||
if (loadingState) {
|
||||
binding.loginLoadingLayout.visibility = View.VISIBLE
|
||||
|
|
@ -276,19 +307,28 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
|
||||
private fun isAdditionalLogin(): Boolean {
|
||||
return intent.getBooleanExtra(LOGIN_MODE, false)
|
||||
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN
|
||||
}
|
||||
|
||||
private fun isAccountMigration(): Boolean {
|
||||
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LoginActivity" // logging tag
|
||||
private const val OAUTH_SCOPES = "read write follow"
|
||||
private const val OAUTH_SCOPES = "read write follow push"
|
||||
private const val LOGIN_MODE = "LOGIN_MODE"
|
||||
private const val DOMAIN = "domain"
|
||||
private const val CLIENT_ID = "clientId"
|
||||
private const val CLIENT_SECRET = "clientSecret"
|
||||
|
||||
const val MODE_DEFAULT = 0
|
||||
const val MODE_ADDITIONAL_LOGIN = 1
|
||||
// "Migration" is used to update the OAuth scope granted to the client
|
||||
const val MODE_MIGRATION = 2
|
||||
|
||||
@JvmStatic
|
||||
fun getIntent(context: Context, mode: Boolean): Intent {
|
||||
fun getIntent(context: Context, mode: Int): Intent {
|
||||
val loginIntent = Intent(context, LoginActivity::class.java)
|
||||
loginIntent.putExtra(LOGIN_MODE, mode)
|
||||
return loginIntent
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
|
||||
val data = OauthLogin.parseData(intent)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import com.keylesspalace.tusky.entity.Notification;
|
|||
import com.keylesspalace.tusky.entity.Poll;
|
||||
import com.keylesspalace.tusky.entity.PollOption;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
|
|
@ -457,24 +458,6 @@ public class NotificationHelper {
|
|||
}
|
||||
}
|
||||
|
||||
public static void deleteLegacyNotificationChannels(@NonNull Context context, @NonNull AccountManager accountManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
// used until Tusky 1.4
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_MENTION);
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_BOOST);
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_FOLLOW);
|
||||
|
||||
// used until Tusky 1.7
|
||||
for(AccountEntity account: accountManager.getAllAccountsOrderedByActive()) {
|
||||
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE+" "+account.getIdentifier());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
|
|
@ -539,13 +522,18 @@ public class NotificationHelper {
|
|||
}
|
||||
}
|
||||
|
||||
private static boolean filterNotification(AccountEntity account, Notification notification,
|
||||
public static boolean filterNotification(AccountEntity account, Notification notification,
|
||||
Context context) {
|
||||
return filterNotification(account, notification.getType(), context);
|
||||
}
|
||||
|
||||
public static boolean filterNotification(AccountEntity account, Notification.Type type,
|
||||
Context context) {
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
String channelId = getChannelId(account, notification);
|
||||
String channelId = getChannelId(account, type);
|
||||
if(channelId == null) {
|
||||
// unknown notificationtype
|
||||
return false;
|
||||
|
|
@ -554,7 +542,7 @@ public class NotificationHelper {
|
|||
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
|
||||
}
|
||||
|
||||
switch (notification.getType()) {
|
||||
switch (type) {
|
||||
case MENTION:
|
||||
return account.getNotificationsMentioned();
|
||||
case STATUS:
|
||||
|
|
@ -580,7 +568,12 @@ public class NotificationHelper {
|
|||
|
||||
@Nullable
|
||||
private static String getChannelId(AccountEntity account, Notification notification) {
|
||||
switch (notification.getType()) {
|
||||
return getChannelId(account, notification.getType());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String getChannelId(AccountEntity account, Notification.Type type) {
|
||||
switch (type) {
|
||||
case MENTION:
|
||||
return CHANNEL_MENTION + account.getIdentifier();
|
||||
case STATUS:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
@file:JvmName("PushNotificationHelper")
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.connyduck.calladapter.networkresult.onFailure
|
||||
import at.connyduck.calladapter.networkresult.onSuccess
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.CryptoUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import retrofit2.HttpException
|
||||
|
||||
private const val TAG = "PushNotificationHelper"
|
||||
|
||||
private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
|
||||
|
||||
private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean =
|
||||
accountManager.accounts.any(::accountNeedsMigration)
|
||||
|
||||
private fun accountNeedsMigration(account: AccountEntity): Boolean =
|
||||
!account.oauthScopes.contains("push")
|
||||
|
||||
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
|
||||
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
|
||||
|
||||
fun showMigrationNoticeIfNecessary(
|
||||
context: Context,
|
||||
parent: View,
|
||||
anchorView: View?,
|
||||
accountManager: AccountManager
|
||||
) {
|
||||
// No point showing anything if we cannot enable it
|
||||
if (!isUnifiedPushAvailable(context)) return
|
||||
if (!anyAccountNeedsMigration(accountManager)) return
|
||||
|
||||
val pm = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
|
||||
|
||||
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAnchorView(anchorView)
|
||||
.setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
|
||||
AlertDialog.Builder(context).apply {
|
||||
if (currentAccountNeedsMigration(accountManager)) {
|
||||
setMessage(R.string.dialog_push_notification_migration)
|
||||
setPositiveButton(R.string.title_migration_relogin) { _, _ ->
|
||||
context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION))
|
||||
}
|
||||
} else {
|
||||
setMessage(R.string.dialog_push_notification_migration_other_accounts)
|
||||
}
|
||||
setNegativeButton(R.string.action_dismiss) { dialog, _ ->
|
||||
val pm = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply()
|
||||
dialog.dismiss()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
|
||||
if (isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Already registered, update the subscription to match notification settings
|
||||
updateUnifiedPushSubscription(context, api, accountManager, account)
|
||||
} else {
|
||||
UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
|
||||
}
|
||||
}
|
||||
|
||||
fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) {
|
||||
if (!isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Not registered
|
||||
return
|
||||
}
|
||||
|
||||
UnifiedPush.unregisterApp(context, account.id.toString())
|
||||
}
|
||||
|
||||
fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
|
||||
account.unifiedPushUrl.isNotEmpty()
|
||||
|
||||
private fun isUnifiedPushAvailable(context: Context): Boolean =
|
||||
UnifiedPush.getDistributors(context).isNotEmpty()
|
||||
|
||||
fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean =
|
||||
isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager)
|
||||
|
||||
suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) {
|
||||
if (!canEnablePushNotifications(context, accountManager)) {
|
||||
// No UP distributors
|
||||
NotificationHelper.enablePullNotifications(context)
|
||||
return
|
||||
}
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
accountManager.accounts.forEach {
|
||||
val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 ||
|
||||
nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false
|
||||
val shouldEnable = it.notificationsEnabled && notificationGroupEnabled
|
||||
|
||||
if (shouldEnable) {
|
||||
enableUnifiedPushNotificationsForAccount(context, api, accountManager, it)
|
||||
} else {
|
||||
disableUnifiedPushNotificationsForAccount(context, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disablePushNotifications(context: Context, accountManager: AccountManager) {
|
||||
accountManager.accounts.forEach {
|
||||
disableUnifiedPushNotificationsForAccount(context, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableAllNotifications(context: Context, accountManager: AccountManager) {
|
||||
disablePushNotifications(context, accountManager)
|
||||
NotificationHelper.disablePullNotifications(context)
|
||||
}
|
||||
|
||||
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
|
||||
buildMap {
|
||||
Notification.Type.asList.forEach {
|
||||
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
|
||||
}
|
||||
}
|
||||
|
||||
// Called by UnifiedPush callback
|
||||
suspend fun registerUnifiedPushEndpoint(
|
||||
context: Context,
|
||||
api: MastodonApi,
|
||||
accountManager: AccountManager,
|
||||
account: AccountEntity,
|
||||
endpoint: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
|
||||
// Generate a prime256v1 key pair for WebPush
|
||||
// Decryption is unimplemented for now, since Mastodon uses an old WebPush
|
||||
// standard which does not send needed information for decryption in the payload
|
||||
// This makes it not directly compatible with UnifiedPush
|
||||
// As of now, we use it purely as a way to trigger a pull
|
||||
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
|
||||
val auth = CryptoUtil.secureRandomBytesEncoded(16)
|
||||
|
||||
api.subscribePushNotifications(
|
||||
"Bearer ${account.accessToken}", account.domain,
|
||||
endpoint, keyPair.pubkey, auth,
|
||||
buildSubscriptionData(context, account)
|
||||
).onFailure { throwable ->
|
||||
Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
|
||||
disableUnifiedPushNotificationsForAccount(context, account)
|
||||
}.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
|
||||
|
||||
account.pushPubKey = keyPair.pubkey
|
||||
account.pushPrivKey = keyPair.privKey
|
||||
account.pushAuth = auth
|
||||
account.pushServerKey = it.serverKey
|
||||
account.unifiedPushUrl = endpoint
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize the enabled / disabled state of notifications with server-side subscription
|
||||
suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.updatePushNotificationSubscription(
|
||||
"Bearer ${account.accessToken}", account.domain,
|
||||
buildSubscriptionData(context, account)
|
||||
).onSuccess {
|
||||
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
|
||||
|
||||
account.pushServerKey = it.serverKey
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
|
||||
.onFailure {
|
||||
Log.d(TAG, "Error unregistering push endpoint for account " + account.id)
|
||||
Log.d(TAG, Log.getStackTraceString(it))
|
||||
Log.d(TAG, (it as HttpException).response().toString())
|
||||
}
|
||||
.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
|
||||
// Clear the URL in database
|
||||
account.unifiedPushUrl = ""
|
||||
account.pushServerKey = ""
|
||||
account.pushAuth = ""
|
||||
account.pushPrivKey = ""
|
||||
account.pushPubKey = ""
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.AccountListActivity
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.FiltersActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
|
|
@ -30,6 +31,8 @@ import com.keylesspalace.tusky.TabPreferenceActivity
|
|||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity
|
||||
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
|
|
@ -139,6 +142,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
if (currentAccountNeedsMigration(accountManager)) {
|
||||
preference {
|
||||
setTitle(R.string.title_migration_relogin)
|
||||
setIcon(R.drawable.ic_logout)
|
||||
setOnPreferenceClickListener {
|
||||
val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
|
||||
(activity as BaseActivity).startActivityWithSlideInAnimation(intent)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preferenceCategory(R.string.pref_publishing) {
|
||||
listPreference {
|
||||
setTitle(R.string.pref_default_post_privacy)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import com.keylesspalace.tusky.entity.DeletedStatus
|
|||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
|
|
|
|||
|
|
@ -103,9 +103,6 @@ class TimelineFragment :
|
|||
private lateinit var adapter: TimelinePagingAdapter
|
||||
|
||||
private var isSwipeToRefreshEnabled = true
|
||||
|
||||
private var layoutManager: LinearLayoutManager? = null
|
||||
private var scrollListener: RecyclerView.OnScrollListener? = null
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -226,7 +223,7 @@ class TimelineFragment :
|
|||
if (actionButtonPresent()) {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
hideFab = preferences.getBoolean("fabHide", false)
|
||||
scrollListener = object : RecyclerView.OnScrollListener() {
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
if (composeButton != null) {
|
||||
|
|
@ -241,9 +238,7 @@ class TimelineFragment :
|
|||
}
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
binding.recyclerView.addOnScrollListener(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
eventHub.events
|
||||
|
|
@ -279,8 +274,7 @@ class TimelineFragment :
|
|||
}
|
||||
)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
binding.recyclerView.layoutManager = layoutManager
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
|
||||
binding.recyclerView.addItemDecoration(divider)
|
||||
|
||||
|
|
@ -471,7 +465,7 @@ class TimelineFragment :
|
|||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
|
||||
if (!useAbsoluteTime) {
|
||||
Observable.interval(1, TimeUnit.MINUTES)
|
||||
Observable.interval(0, 1, TimeUnit.MINUTES)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
|
||||
.subscribe {
|
||||
|
|
@ -482,7 +476,7 @@ class TimelineFragment :
|
|||
|
||||
override fun onReselect() {
|
||||
if (isAdded) {
|
||||
layoutManager!!.scrollToPosition(0)
|
||||
binding.recyclerView.layoutManager?.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
|||
contentShowing = false,
|
||||
pinned = false,
|
||||
card = null,
|
||||
repliesCount = 0
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +141,7 @@ fun Status.toEntity(
|
|||
contentCollapsed = contentCollapsed,
|
||||
pinned = actionableStatus.pinned == true,
|
||||
card = actionableStatus.card?.let(gson::toJson),
|
||||
repliesCount = actionableStatus.repliesCount
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +185,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount
|
||||
)
|
||||
}
|
||||
val status = if (reblog != null) {
|
||||
|
|
@ -211,7 +214,8 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null
|
||||
card = null,
|
||||
repliesCount = status.repliesCount,
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
|
|
@ -240,6 +244,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount,
|
||||
)
|
||||
}
|
||||
return StatusViewData.Concrete(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ class CachedTimelineRemoteMediator(
|
|||
state: PagingState<Int, TimelineStatusWithAccount>
|
||||
): MediatorResult {
|
||||
|
||||
if (!activeAccount.isLoggedIn()) {
|
||||
return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
|
||||
try {
|
||||
var dbEmpty = false
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.filter
|
||||
import androidx.paging.map
|
||||
|
|
@ -37,10 +38,11 @@ import com.keylesspalace.tusky.components.timeline.toViewData
|
|||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
|
|
@ -66,7 +68,16 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
filterModel: FilterModel,
|
||||
private val db: AppDatabase,
|
||||
private val gson: Gson
|
||||
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) {
|
||||
) : TimelineViewModel(
|
||||
timelineCases,
|
||||
api,
|
||||
eventHub,
|
||||
accountManager,
|
||||
sharedPreferences,
|
||||
filterModel
|
||||
) {
|
||||
|
||||
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
override val statuses = Pager(
|
||||
|
|
@ -78,6 +89,8 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
EmptyTimelinePagingSource()
|
||||
} else {
|
||||
db.timelineDao().getStatuses(activeAccount.id)
|
||||
}.also { newPagingSource ->
|
||||
this.currentPagingSource = newPagingSource
|
||||
}
|
||||
}
|
||||
).flow
|
||||
|
|
@ -113,13 +126,15 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
|
||||
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
||||
db.timelineDao()
|
||||
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
|
||||
db.timelineDao()
|
||||
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,12 +161,21 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
|
||||
timelineDao.insertStatus(
|
||||
Placeholder(placeholderId, loading = true).toEntity(
|
||||
activeAccount.id
|
||||
)
|
||||
)
|
||||
|
||||
val response = db.withTransaction {
|
||||
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
|
||||
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
|
||||
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE)
|
||||
val nextPlaceholderId =
|
||||
timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
|
||||
api.homeTimeline(
|
||||
maxId = idAbovePlaceholder,
|
||||
sinceId = nextPlaceholderId,
|
||||
limit = LOAD_AT_ONCE
|
||||
)
|
||||
}.await()
|
||||
|
||||
val statuses = response.body()
|
||||
|
|
@ -165,16 +189,21 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
timelineDao.delete(activeAccount.id, placeholderId)
|
||||
|
||||
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
||||
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
|
||||
timelineDao.deleteRange(
|
||||
activeAccount.id,
|
||||
statuses.last().id,
|
||||
statuses.first().id
|
||||
)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
for (status in statuses) {
|
||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
|
||||
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
}
|
||||
status.reblog?.account?.toEntity(activeAccount.id, gson)
|
||||
?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
}
|
||||
timelineDao.insertStatus(
|
||||
status.toEntity(
|
||||
timelineUserId = activeAccount.id,
|
||||
|
|
@ -193,7 +222,10 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
to guarantee the placeholder has an id that exists on the server as not all
|
||||
servers handle client generated ids as expected */
|
||||
timelineDao.insertStatus(
|
||||
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
|
||||
Placeholder(
|
||||
statuses.last().id,
|
||||
loading = false
|
||||
).toEntity(activeAccount.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -208,7 +240,8 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
|
||||
Log.w("CachedTimelineVM", "failed loading statuses", e)
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
db.timelineDao()
|
||||
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
}
|
||||
|
||||
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
||||
|
|
@ -234,6 +267,13 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun invalidate() {
|
||||
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
|
||||
if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) {
|
||||
currentPagingSource?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_STATUSES_IN_CACHE = 1000
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Poll
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.getDomain
|
||||
import com.keylesspalace.tusky.util.isLessThan
|
||||
import com.keylesspalace.tusky.util.isLessThanOrEqual
|
||||
|
|
@ -249,6 +249,10 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
currentSource?.invalidate()
|
||||
}
|
||||
|
||||
override suspend fun invalidate() {
|
||||
currentSource?.invalidate()
|
||||
}
|
||||
|
||||
@Throws(IOException::class, HttpException::class)
|
||||
suspend fun fetchStatusesForKind(
|
||||
fromId: String?,
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ import com.keylesspalace.tusky.entity.Filter
|
|||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -81,6 +81,7 @@ abstract class TimelineViewModel(
|
|||
this.tags = tags
|
||||
|
||||
if (kind == Kind.HOME) {
|
||||
// Note the variable is "true if filter" but the underlying preference/settings text is "true if show"
|
||||
filterRemoveReplies =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||
filterRemoveReblogs =
|
||||
|
|
@ -172,6 +173,9 @@ abstract class TimelineViewModel(
|
|||
|
||||
abstract fun fullReload()
|
||||
|
||||
/** Triggered when currently displayed data must be reloaded. */
|
||||
protected abstract suspend fun invalidate()
|
||||
|
||||
protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean {
|
||||
val status = statusViewData.asStatusOrNull()?.status ?: return false
|
||||
return status.inReplyToId != null && filterRemoveReplies ||
|
||||
|
|
@ -287,6 +291,9 @@ abstract class TimelineViewModel(
|
|||
filterContextMatchesKind(kind, it.context)
|
||||
}
|
||||
)
|
||||
// After the filters are loaded we need to reload displayed content to apply them.
|
||||
// It can happen during the usage or at startup, when we get statuses before filters.
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ data class AccountEntity(
|
|||
@field:PrimaryKey(autoGenerate = true) var id: Long,
|
||||
val domain: String,
|
||||
var accessToken: String,
|
||||
var clientId: String?, // nullable for backward compatibility
|
||||
var clientSecret: String?, // nullable for backward compatibility
|
||||
var isActive: Boolean,
|
||||
var accountId: String = "",
|
||||
var username: String = "",
|
||||
|
|
@ -64,7 +66,15 @@ data class AccountEntity(
|
|||
var activeNotifications: String = "[]",
|
||||
var emojis: List<Emoji> = emptyList(),
|
||||
var tabPreferences: List<TabData> = defaultTabs(),
|
||||
var notificationsFilter: String = "[\"follow_request\"]"
|
||||
var notificationsFilter: String = "[\"follow_request\"]",
|
||||
// Scope cannot be changed without re-login, so store it in case
|
||||
// the scope needs to be changed in the future
|
||||
var oauthScopes: String = "",
|
||||
var unifiedPushUrl: String = "",
|
||||
var pushPubKey: String = "",
|
||||
var pushPrivKey: String = "",
|
||||
var pushAuth: String = "",
|
||||
var pushServerKey: String = "",
|
||||
) {
|
||||
|
||||
val identifier: String
|
||||
|
|
@ -73,6 +83,15 @@ data class AccountEntity(
|
|||
val fullName: String
|
||||
get() = "@$username@$domain"
|
||||
|
||||
fun logout() {
|
||||
// deleting credentials so they cannot be used again
|
||||
accessToken = ""
|
||||
clientId = null
|
||||
clientSecret = null
|
||||
}
|
||||
|
||||
fun isLoggedIn() = accessToken.isNotEmpty()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
|
|
|||
|
|
@ -48,13 +48,22 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds a new empty account and makes it the active account.
|
||||
* More account information has to be added later with [updateActiveAccount]
|
||||
* or the account wont be saved to the database.
|
||||
* Adds a new account and makes it the active account.
|
||||
* @param accessToken the access token for the new account
|
||||
* @param domain the domain of the accounts Mastodon instance
|
||||
* @param clientId the oauth client id used to sign in the account
|
||||
* @param clientSecret the oauth client secret used to sign in the account
|
||||
* @param oauthScopes the oauth scopes granted to the account
|
||||
* @param newAccount the [Account] as returned by the Mastodon Api
|
||||
*/
|
||||
fun addAccount(accessToken: String, domain: String) {
|
||||
fun addAccount(
|
||||
accessToken: String,
|
||||
domain: String,
|
||||
clientId: String,
|
||||
clientSecret: String,
|
||||
oauthScopes: String,
|
||||
newAccount: Account
|
||||
) {
|
||||
|
||||
activeAccount?.let {
|
||||
it.isActive = false
|
||||
|
|
@ -62,10 +71,35 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
|
||||
accountDao.insertOrReplace(it)
|
||||
}
|
||||
// check if this is a relogin with an existing account, if yes update it, otherwise create a new one
|
||||
val existingAccountIndex = accounts.indexOfFirst { account ->
|
||||
domain == account.domain && newAccount.id == account.accountId
|
||||
}
|
||||
val newAccountEntity = if (existingAccountIndex != -1) {
|
||||
accounts[existingAccountIndex].copy(
|
||||
accessToken = accessToken,
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
oauthScopes = oauthScopes,
|
||||
isActive = true
|
||||
).also { accounts[existingAccountIndex] = it }
|
||||
} else {
|
||||
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
|
||||
val newAccountId = maxAccountId + 1
|
||||
AccountEntity(
|
||||
id = newAccountId,
|
||||
domain = domain.lowercase(Locale.ROOT),
|
||||
accessToken = accessToken,
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
oauthScopes = oauthScopes,
|
||||
isActive = true,
|
||||
accountId = newAccount.id
|
||||
).also { accounts.add(it) }
|
||||
}
|
||||
|
||||
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
|
||||
val newAccountId = maxAccountId + 1
|
||||
activeAccount = AccountEntity(id = newAccountId, domain = domain.lowercase(Locale.ROOT), accessToken = accessToken, isActive = true)
|
||||
activeAccount = newAccountEntity
|
||||
updateActiveAccount(newAccount)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -86,11 +120,12 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
*/
|
||||
fun logActiveAccountOut(): AccountEntity? {
|
||||
|
||||
if (activeAccount == null) {
|
||||
return null
|
||||
} else {
|
||||
accounts.remove(activeAccount!!)
|
||||
accountDao.delete(activeAccount!!)
|
||||
return activeAccount?.let { account ->
|
||||
|
||||
account.logout()
|
||||
|
||||
accounts.remove(account)
|
||||
accountDao.delete(account)
|
||||
|
||||
if (accounts.size > 0) {
|
||||
accounts[0].isActive = true
|
||||
|
|
@ -100,7 +135,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
} else {
|
||||
activeAccount = null
|
||||
}
|
||||
return activeAccount
|
||||
activeAccount
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,17 +155,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
it.emojis = account.emojis ?: emptyList()
|
||||
|
||||
Log.d(TAG, "updateActiveAccount: saving account with id " + it.id)
|
||||
it.id = accountDao.insertOrReplace(it)
|
||||
|
||||
val accountIndex = accounts.indexOf(it)
|
||||
|
||||
if (accountIndex != -1) {
|
||||
// in case the user was already logged in with this account, remove the old information
|
||||
accounts.removeAt(accountIndex)
|
||||
accounts.add(accountIndex, it)
|
||||
} else {
|
||||
accounts.add(it)
|
||||
}
|
||||
accountDao.insertOrReplace(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,4 +214,15 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
id == accountId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an account by its string identifier
|
||||
* @param identifier the string identifier of the account
|
||||
* @return the requested account or null if it was not found
|
||||
*/
|
||||
fun getAccountByIdentifier(identifier: String): AccountEntity? {
|
||||
return accounts.find {
|
||||
identifier == it.identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import java.io.File;
|
|||
*/
|
||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 35)
|
||||
}, version = 39)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract AccountDao accountDao();
|
||||
|
|
@ -541,4 +541,44 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_35_36 = new Migration(35, 36) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_36_37 = new Migration(36, 37) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `repliesCount` INTEGER NOT NULL DEFAULT 0");
|
||||
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_repliesCount` INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_37_38 = new Migration(37, 38) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
// database needs to be cleaned because the ConversationAccountEntity got a new attribute
|
||||
database.execSQL("DELETE FROM `ConversationEntity`");
|
||||
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
// timestamps are now serialized differently so all cache tables that contain them need to be cleaned
|
||||
database.execSQL("DELETE FROM `TimelineStatusEntity`");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_38_39 = new Migration(38, 39) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientId` TEXT");
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@ interface ConversationsDao {
|
|||
suspend fun insert(conversations: List<ConversationEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(conversation: ConversationEntity): Long
|
||||
suspend fun insert(conversation: ConversationEntity)
|
||||
|
||||
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
|
||||
suspend fun delete(id: String, accountId: Long): Int
|
||||
suspend fun delete(id: String, accountId: Long)
|
||||
|
||||
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
|
||||
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY `order` ASC")
|
||||
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>
|
||||
|
||||
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
|
||||
fun deleteForAccount(accountId: Long)
|
||||
suspend fun deleteForAccount(accountId: Long)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.room.Dao
|
|||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
|
||||
@Dao
|
||||
interface InstanceDao {
|
||||
|
|
@ -29,9 +30,11 @@ interface InstanceDao {
|
|||
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
|
||||
suspend fun insertOrReplace(emojis: EmojisEntity)
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
suspend fun getInstanceInfo(instance: String): InstanceInfoEntity?
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
suspend fun getEmojiInfo(instance: String): EmojisEntity?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ abstract class TimelineDao {
|
|||
"""
|
||||
SELECT s.serverId, s.url, s.timelineUserId,
|
||||
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
|
||||
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
||||
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
||||
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
|
||||
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned,
|
||||
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
|
||||
|
|
@ -197,4 +197,7 @@ AND timelineUserId = :accountId
|
|||
*/
|
||||
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
|
||||
abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
|
||||
abstract suspend fun getStatusCount(accountId: Long): Int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ data class TimelineStatusEntity(
|
|||
val emojis: String?,
|
||||
val reblogsCount: Int,
|
||||
val favouritesCount: Int,
|
||||
val repliesCount: Int,
|
||||
val reblogged: Boolean,
|
||||
val bookmarked: Boolean,
|
||||
val favourited: Boolean,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ class AppModule {
|
|||
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
|
||||
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
|
||||
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
|
||||
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
|
||||
AppDatabase.MIGRATION_38_39
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@
|
|||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
|
|
@ -28,4 +30,10 @@ abstract class BroadcastReceiverModule {
|
|||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeNotificationBlockStateBroadcastReceiver(): NotificationBlockStateBroadcastReceiver
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@ package com.keylesspalace.tusky.di
|
|||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
|
||||
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter
|
||||
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.MediaUploadApi
|
||||
|
|
@ -38,6 +40,7 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||
import retrofit2.create
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -50,7 +53,9 @@ class NetworkModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesGson() = Gson()
|
||||
fun providesGson(): Gson = GsonBuilder()
|
||||
.registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter())
|
||||
.create()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -106,7 +111,7 @@ class NetworkModule {
|
|||
.client(httpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
|
||||
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create())
|
||||
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ data class Account(
|
|||
@SerializedName("username") val localUsername: String,
|
||||
@SerializedName("acct") val username: String,
|
||||
@SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
|
||||
@SerializedName("created_at") val createdAt: Date,
|
||||
val note: String,
|
||||
val url: String,
|
||||
val avatar: String,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.entity
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class NotificationSubscribeResult(
|
||||
val id: Int,
|
||||
val endpoint: String,
|
||||
@SerializedName("server_key") val serverKey: String,
|
||||
)
|
||||
|
|
@ -34,6 +34,7 @@ data class Status(
|
|||
val emojis: List<Emoji>,
|
||||
@SerializedName("reblogs_count") val reblogsCount: Int,
|
||||
@SerializedName("favourites_count") val favouritesCount: Int,
|
||||
@SerializedName("replies_count") val repliesCount: Int,
|
||||
var reblogged: Boolean,
|
||||
var favourited: Boolean,
|
||||
var bookmarked: Boolean,
|
||||
|
|
|
|||
|
|
@ -538,7 +538,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
|
||||
@Override
|
||||
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
||||
updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed));
|
||||
updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed));
|
||||
;
|
||||
}
|
||||
|
||||
|
|
@ -963,10 +963,10 @@ public class NotificationsFragment extends SFragment implements
|
|||
if (notifications.size() == 0 && adapter.getItemCount() == 0) {
|
||||
this.statusView.setVisibility(View.VISIBLE);
|
||||
this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null);
|
||||
} else {
|
||||
swipeRefreshLayout.setEnabled(true);
|
||||
}
|
||||
|
||||
updateFilterVisibility();
|
||||
swipeRefreshLayout.setEnabled(true);
|
||||
swipeRefreshLayout.setRefreshing(false);
|
||||
progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
|
@ -1231,7 +1231,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
|
||||
boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
|
||||
if (!useAbsoluteTime) {
|
||||
Observable.interval(1, TimeUnit.MINUTES)
|
||||
Observable.interval(0, 1, TimeUnit.MINUTES)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE)))
|
||||
.subscribe(
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import com.keylesspalace.tusky.di.Injectable;
|
|||
import com.keylesspalace.tusky.entity.Attachment;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.network.TimelineCases;
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.StatusParsingHelper;
|
||||
import com.keylesspalace.tusky.view.MuteAccountDialog;
|
||||
|
|
|
|||
|
|
@ -387,7 +387,7 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
public void onContentCollapsedChange(boolean isCollapsed, int position) {
|
||||
adapter.setItem(
|
||||
position,
|
||||
statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed),
|
||||
statuses.getPairedItem(position).copyWithCollapsed(isCollapsed),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
|
|
|||
268
app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt
Normal file
268
app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package com.keylesspalace.tusky.json
|
||||
|
||||
/*
|
||||
* Copyright (C) 2011 FasterXML, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import com.google.gson.JsonParseException
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/*
|
||||
* Jackson’s date formatter, pruned to Moshi's needs. Forked from this file:
|
||||
* https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java
|
||||
*
|
||||
* Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC
|
||||
* friendly than using SimpleDateFormat so highly suitable if you (un)serialize lots of date
|
||||
* objects.
|
||||
*
|
||||
* Supported parse format:
|
||||
* `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]`
|
||||
*
|
||||
* @see [this specification](http://www.w3.org/TR/NOTE-datetime)
|
||||
*/
|
||||
|
||||
/** ID to represent the 'GMT' string */
|
||||
private const val GMT_ID = "GMT"
|
||||
|
||||
/** The GMT timezone, prefetched to avoid more lookups. */
|
||||
private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID)
|
||||
|
||||
/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */
|
||||
internal fun Date.formatIsoDate(): String {
|
||||
val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US)
|
||||
calendar.time = this
|
||||
|
||||
// estimate capacity of buffer as close as we can (yeah, that's pedantic ;)
|
||||
val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length
|
||||
val formatted = StringBuilder(capacity)
|
||||
padInt(formatted, calendar[Calendar.YEAR], "yyyy".length)
|
||||
formatted.append('-')
|
||||
padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length)
|
||||
formatted.append('-')
|
||||
padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length)
|
||||
formatted.append('T')
|
||||
padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length)
|
||||
formatted.append(':')
|
||||
padInt(formatted, calendar[Calendar.MINUTE], "mm".length)
|
||||
formatted.append(':')
|
||||
padInt(formatted, calendar[Calendar.SECOND], "ss".length)
|
||||
formatted.append('.')
|
||||
padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length)
|
||||
formatted.append('Z')
|
||||
return formatted.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date from ISO-8601 formatted string. It expects a format
|
||||
* `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]`
|
||||
*
|
||||
* @receiver ISO string to parse in the appropriate format.
|
||||
* @return the parsed date
|
||||
*/
|
||||
internal fun String.parseIsoDate(): Date {
|
||||
return try {
|
||||
var offset = 0
|
||||
|
||||
// extract year
|
||||
val year = parseInt(this, offset, 4.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, '-')) {
|
||||
offset += 1
|
||||
}
|
||||
|
||||
// extract month
|
||||
val month = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, '-')) {
|
||||
offset += 1
|
||||
}
|
||||
|
||||
// extract day
|
||||
val day = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
// default time value
|
||||
var hour = 0
|
||||
var minutes = 0
|
||||
var seconds = 0
|
||||
// always use 0 otherwise returned date will include millis of current time
|
||||
var milliseconds = 0
|
||||
|
||||
// if the value has no time component (and no time zone), we are done
|
||||
val hasT = checkOffset(this, offset, 'T')
|
||||
if (!hasT && this.length <= offset) {
|
||||
return GregorianCalendar(year, month - 1, day).time
|
||||
}
|
||||
if (hasT) {
|
||||
|
||||
// extract hours, minutes, seconds and milliseconds
|
||||
hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, ':')) {
|
||||
offset += 1
|
||||
}
|
||||
minutes = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
if (checkOffset(this, offset, ':')) {
|
||||
offset += 1
|
||||
}
|
||||
// second and milliseconds can be optional
|
||||
if (this.length > offset) {
|
||||
val c = this[offset]
|
||||
if (c != 'Z' && c != '+' && c != '-') {
|
||||
seconds = parseInt(this, offset, 2.let { offset += it; offset })
|
||||
if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds
|
||||
// milliseconds can be optional in the format
|
||||
if (checkOffset(this, offset, '.')) {
|
||||
offset += 1
|
||||
val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit
|
||||
val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits
|
||||
val fraction = parseInt(this, offset, parseEndOffset)
|
||||
milliseconds =
|
||||
(10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt()
|
||||
offset = endOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extract timezone
|
||||
require(this.length > offset) { "No time zone indicator" }
|
||||
val timezone: TimeZone
|
||||
val timezoneIndicator = this[offset]
|
||||
if (timezoneIndicator == 'Z') {
|
||||
timezone = TIMEZONE_Z
|
||||
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
|
||||
val timezoneOffset = this.substring(offset)
|
||||
// 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00"
|
||||
if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) {
|
||||
timezone = TIMEZONE_Z
|
||||
} else {
|
||||
// 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC...
|
||||
// not sure why, but it is what it is.
|
||||
val timezoneId = GMT_ID + timezoneOffset
|
||||
timezone = TimeZone.getTimeZone(timezoneId)
|
||||
val act = timezone.id
|
||||
if (act != timezoneId) {
|
||||
/*
|
||||
* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given
|
||||
* one without. If so, don't sweat.
|
||||
* Yes, very inefficient. Hopefully not hit often.
|
||||
* If it becomes a perf problem, add 'loose' comparison instead.
|
||||
*/
|
||||
val cleaned = act.replace(":", "")
|
||||
if (cleaned != timezoneId) {
|
||||
throw IndexOutOfBoundsException(
|
||||
"Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw IndexOutOfBoundsException(
|
||||
"Invalid time zone indicator '$timezoneIndicator'"
|
||||
)
|
||||
}
|
||||
val calendar: Calendar = GregorianCalendar(timezone)
|
||||
calendar.isLenient = false
|
||||
calendar[Calendar.YEAR] = year
|
||||
calendar[Calendar.MONTH] = month - 1
|
||||
calendar[Calendar.DAY_OF_MONTH] = day
|
||||
calendar[Calendar.HOUR_OF_DAY] = hour
|
||||
calendar[Calendar.MINUTE] = minutes
|
||||
calendar[Calendar.SECOND] = seconds
|
||||
calendar[Calendar.MILLISECOND] = milliseconds
|
||||
calendar.time
|
||||
// If we get a ParseException it'll already have the right message/offset.
|
||||
// Other exception types can convert here.
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
throw JsonParseException("Not an RFC 3339 date: $this", e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw JsonParseException("Not an RFC 3339 date: $this", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the expected character exist at the given offset in the value.
|
||||
*
|
||||
* @param value the string to check at the specified offset
|
||||
* @param offset the offset to look for the expected character
|
||||
* @param expected the expected character
|
||||
* @return true if the expected character exist at the given offset
|
||||
*/
|
||||
private fun checkOffset(value: String, offset: Int, expected: Char): Boolean {
|
||||
return offset < value.length && value[offset] == expected
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an integer located between 2 given offsets in a string
|
||||
*
|
||||
* @param value the string to parse
|
||||
* @param beginIndex the start index for the integer in the string
|
||||
* @param endIndex the end index for the integer in the string
|
||||
* @return the int
|
||||
* @throws NumberFormatException if the value is not a number
|
||||
*/
|
||||
private fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int {
|
||||
if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) {
|
||||
throw NumberFormatException(value)
|
||||
}
|
||||
// use same logic as in Integer.parseInt() but less generic we're not supporting negative values
|
||||
var i = beginIndex
|
||||
var result = 0
|
||||
var digit: Int
|
||||
if (i < endIndex) {
|
||||
digit = Character.digit(value[i++], 10)
|
||||
if (digit < 0) {
|
||||
throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex))
|
||||
}
|
||||
result = -digit
|
||||
}
|
||||
while (i < endIndex) {
|
||||
digit = Character.digit(value[i++], 10)
|
||||
if (digit < 0) {
|
||||
throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex))
|
||||
}
|
||||
result *= 10
|
||||
result -= digit
|
||||
}
|
||||
return -result
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero pad a number to a specified length
|
||||
*
|
||||
* @param buffer buffer to use for padding
|
||||
* @param value the integer value to pad if necessary.
|
||||
* @param length the length of the string we should zero pad
|
||||
*/
|
||||
private fun padInt(buffer: StringBuilder, value: Int, length: Int) {
|
||||
val strValue = value.toString()
|
||||
for (i in length - strValue.length downTo 1) {
|
||||
buffer.append('0')
|
||||
}
|
||||
buffer.append(strValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first character in the string that is not a digit, starting at offset.
|
||||
*/
|
||||
private fun indexOfNonDigit(string: String, offset: Int): Int {
|
||||
for (i in offset until string.length) {
|
||||
val c = string[i]
|
||||
if (c < '0' || c > '9') return i
|
||||
}
|
||||
return string.length
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java
|
||||
/*
|
||||
* Copyright (C) 2011 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.keylesspalace.tusky.json
|
||||
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.google.gson.stream.JsonToken
|
||||
import com.google.gson.stream.JsonWriter
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
|
||||
class Rfc3339DateJsonAdapter : TypeAdapter<Date?>() {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(writer: JsonWriter, date: Date?) {
|
||||
if (date == null) {
|
||||
writer.nullValue()
|
||||
} else {
|
||||
writer.value(date.formatIsoDate())
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(reader: JsonReader): Date? {
|
||||
return when (reader.peek()) {
|
||||
JsonToken.NULL -> {
|
||||
reader.nextNull()
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
reader.nextString().parseIsoDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
/* Copyright 2018 charlag
|
||||
*
|
||||
* 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.network;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* Created by charlag on 31/10/17.
|
||||
*/
|
||||
|
||||
public final class InstanceSwitchAuthInterceptor implements Interceptor {
|
||||
private AccountManager accountManager;
|
||||
|
||||
public InstanceSwitchAuthInterceptor(AccountManager accountManager) {
|
||||
this.accountManager = accountManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response intercept(@NonNull Chain chain) throws IOException {
|
||||
|
||||
Request originalRequest = chain.request();
|
||||
|
||||
// only switch domains if the request comes from retrofit
|
||||
if (originalRequest.url().host().equals(MastodonApi.PLACEHOLDER_DOMAIN)) {
|
||||
AccountEntity currentAccount = accountManager.getActiveAccount();
|
||||
|
||||
Request.Builder builder = originalRequest.newBuilder();
|
||||
|
||||
String instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER);
|
||||
if (instanceHeader != null) {
|
||||
// use domain explicitly specified in custom header
|
||||
builder.url(swapHost(originalRequest.url(), instanceHeader));
|
||||
builder.removeHeader(MastodonApi.DOMAIN_HEADER);
|
||||
} else if (currentAccount != null) {
|
||||
//use domain of current account
|
||||
builder.url(swapHost(originalRequest.url(), currentAccount.getDomain()))
|
||||
.header("Authorization",
|
||||
String.format("Bearer %s", currentAccount.getAccessToken()));
|
||||
}
|
||||
Request newRequest = builder.build();
|
||||
|
||||
return chain.proceed(newRequest);
|
||||
|
||||
} else {
|
||||
return chain.proceed(originalRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static HttpUrl swapHost(@NonNull HttpUrl url, @NonNull String host) {
|
||||
return url.newBuilder().host(host).build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/* Copyright 2022 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.network
|
||||
|
||||
import android.util.Log
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.IOException
|
||||
|
||||
class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
|
||||
// only switch domains if the request comes from retrofit
|
||||
return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) {
|
||||
|
||||
val builder: Request.Builder = originalRequest.newBuilder()
|
||||
val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER)
|
||||
|
||||
if (instanceHeader != null) {
|
||||
// use domain explicitly specified in custom header
|
||||
builder.url(swapHost(originalRequest.url, instanceHeader))
|
||||
builder.removeHeader(MastodonApi.DOMAIN_HEADER)
|
||||
} else {
|
||||
val currentAccount = accountManager.activeAccount
|
||||
|
||||
if (currentAccount != null) {
|
||||
val accessToken = currentAccount.accessToken
|
||||
if (accessToken.isNotEmpty()) {
|
||||
// use domain of current account
|
||||
builder.url(swapHost(originalRequest.url, currentAccount.domain))
|
||||
.header("Authorization", "Bearer %s".format(accessToken))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val newRequest: Request = builder.build()
|
||||
|
||||
if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) {
|
||||
Log.w("ISAInterceptor", "no user logged in or no domain header specified - can't make request to " + newRequest.url)
|
||||
return Response.Builder()
|
||||
.code(400)
|
||||
.message("Bad Request")
|
||||
.protocol(Protocol.HTTP_2)
|
||||
.body("".toResponseBody("text/plain".toMediaType()))
|
||||
.request(chain.request())
|
||||
.build()
|
||||
}
|
||||
|
||||
chain.proceed(newRequest)
|
||||
} else {
|
||||
chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun swapHost(url: HttpUrl, host: String): HttpUrl {
|
||||
return url.newBuilder().host(host).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.network
|
||||
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.entity.AccessToken
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
|
|
@ -30,6 +31,7 @@ import com.keylesspalace.tusky.entity.MastoList
|
|||
import com.keylesspalace.tusky.entity.MediaUploadResult
|
||||
import com.keylesspalace.tusky.entity.NewStatus
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.NotificationSubscribeResult
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
|
|
@ -37,7 +39,6 @@ import com.keylesspalace.tusky.entity.SearchResult
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.entity.StatusContext
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
|
|
@ -47,6 +48,7 @@ import retrofit2.Response
|
|||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FieldMap
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
|
|
@ -72,14 +74,11 @@ interface MastodonApi {
|
|||
const val PLACEHOLDER_DOMAIN = "dummy.placeholder"
|
||||
}
|
||||
|
||||
@GET("/api/v1/lists")
|
||||
fun getLists(): Single<List<MastoList>>
|
||||
|
||||
@GET("/api/v1/custom_emojis")
|
||||
suspend fun getCustomEmojis(): Result<List<Emoji>>
|
||||
suspend fun getCustomEmojis(): NetworkResult<List<Emoji>>
|
||||
|
||||
@GET("api/v1/instance")
|
||||
suspend fun getInstance(): Result<Instance>
|
||||
suspend fun getInstance(): NetworkResult<Instance>
|
||||
|
||||
@GET("api/v1/filters")
|
||||
fun getFilters(): Single<List<Filter>>
|
||||
|
|
@ -147,7 +146,7 @@ interface MastodonApi {
|
|||
suspend fun updateMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
@Field("description") description: String
|
||||
): Result<Attachment>
|
||||
): NetworkResult<Attachment>
|
||||
|
||||
@GET("api/v1/media/{mediaId}")
|
||||
suspend fun getMedia(
|
||||
|
|
@ -160,7 +159,7 @@ interface MastodonApi {
|
|||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Header("Idempotency-Key") idempotencyKey: String,
|
||||
@Body status: NewStatus
|
||||
): Result<Status>
|
||||
): NetworkResult<Status>
|
||||
|
||||
@GET("api/v1/statuses/{id}")
|
||||
fun status(
|
||||
|
|
@ -248,10 +247,13 @@ interface MastodonApi {
|
|||
@DELETE("api/v1/scheduled_statuses/{id}")
|
||||
suspend fun deleteScheduledStatus(
|
||||
@Path("id") scheduledStatusId: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@GET("api/v1/accounts/verify_credentials")
|
||||
suspend fun accountVerifyCredentials(): Result<Account>
|
||||
suspend fun accountVerifyCredentials(
|
||||
@Header(DOMAIN_HEADER) domain: String? = null,
|
||||
@Header("Authorization") auth: String? = null,
|
||||
): NetworkResult<Account>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PATCH("api/v1/accounts/update_credentials")
|
||||
|
|
@ -276,15 +278,23 @@ interface MastodonApi {
|
|||
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
|
||||
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
|
||||
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
|
||||
): Result<Account>
|
||||
): NetworkResult<Account>
|
||||
|
||||
@GET("api/v1/accounts/search")
|
||||
fun searchAccounts(
|
||||
suspend fun searchAccounts(
|
||||
@Query("q") query: String,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): Single<List<TimelineAccount>>
|
||||
): NetworkResult<List<TimelineAccount>>
|
||||
|
||||
@GET("api/v1/accounts/search")
|
||||
fun searchAccountsSync(
|
||||
@Query("q") query: String,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): NetworkResult<List<TimelineAccount>>
|
||||
|
||||
@GET("api/v1/accounts/{id}")
|
||||
fun account(
|
||||
|
|
@ -439,7 +449,7 @@ interface MastodonApi {
|
|||
@Field("redirect_uris") redirectUris: String,
|
||||
@Field("scopes") scopes: String,
|
||||
@Field("website") website: String
|
||||
): Result<AppCredentials>
|
||||
): NetworkResult<AppCredentials>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/token")
|
||||
|
|
@ -450,52 +460,63 @@ interface MastodonApi {
|
|||
@Field("redirect_uri") redirectUri: String,
|
||||
@Field("code") code: String,
|
||||
@Field("grant_type") grantType: String
|
||||
): Result<AccessToken>
|
||||
): NetworkResult<AccessToken>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/revoke")
|
||||
suspend fun revokeOAuthToken(
|
||||
@Field("client_id") clientId: String,
|
||||
@Field("client_secret") clientSecret: String,
|
||||
@Field("token") token: String
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@GET("/api/v1/lists")
|
||||
suspend fun getLists(): NetworkResult<List<MastoList>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/lists")
|
||||
fun createList(
|
||||
suspend fun createList(
|
||||
@Field("title") title: String
|
||||
): Single<MastoList>
|
||||
): NetworkResult<MastoList>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/lists/{listId}")
|
||||
fun updateList(
|
||||
suspend fun updateList(
|
||||
@Path("listId") listId: String,
|
||||
@Field("title") title: String
|
||||
): Single<MastoList>
|
||||
): NetworkResult<MastoList>
|
||||
|
||||
@DELETE("api/v1/lists/{listId}")
|
||||
fun deleteList(
|
||||
suspend fun deleteList(
|
||||
@Path("listId") listId: String
|
||||
): Completable
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@GET("api/v1/lists/{listId}/accounts")
|
||||
fun getAccountsInList(
|
||||
suspend fun getAccountsInList(
|
||||
@Path("listId") listId: String,
|
||||
@Query("limit") limit: Int
|
||||
): Single<List<TimelineAccount>>
|
||||
): NetworkResult<List<TimelineAccount>>
|
||||
|
||||
@FormUrlEncoded
|
||||
// @DELETE doesn't support fields
|
||||
@HTTP(method = "DELETE", path = "api/v1/lists/{listId}/accounts", hasBody = true)
|
||||
fun deleteAccountFromList(
|
||||
suspend fun deleteAccountFromList(
|
||||
@Path("listId") listId: String,
|
||||
@Field("account_ids[]") accountIds: List<String>
|
||||
): Completable
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/lists/{listId}/accounts")
|
||||
fun addCountToList(
|
||||
suspend fun addAccountToList(
|
||||
@Path("listId") listId: String,
|
||||
@Field("account_ids[]") accountIds: List<String>
|
||||
): Completable
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@GET("/api/v1/conversations")
|
||||
suspend fun getConversations(
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("limit") limit: Int
|
||||
): List<Conversation>
|
||||
@Query("limit") limit: Int? = null
|
||||
): Response<List<Conversation>>
|
||||
|
||||
@DELETE("/api/v1/conversations/{id}")
|
||||
suspend fun deleteConversation(
|
||||
|
|
@ -538,24 +559,24 @@ interface MastodonApi {
|
|||
@GET("api/v1/announcements")
|
||||
suspend fun listAnnouncements(
|
||||
@Query("with_dismissed") withDismissed: Boolean = true
|
||||
): Result<List<Announcement>>
|
||||
): NetworkResult<List<Announcement>>
|
||||
|
||||
@POST("api/v1/announcements/{id}/dismiss")
|
||||
suspend fun dismissAnnouncement(
|
||||
@Path("id") announcementId: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@PUT("api/v1/announcements/{id}/reactions/{name}")
|
||||
suspend fun addAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@DELETE("api/v1/announcements/{id}/reactions/{name}")
|
||||
suspend fun removeAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Result<ResponseBody>
|
||||
): NetworkResult<ResponseBody>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/reports")
|
||||
|
|
@ -591,10 +612,48 @@ interface MastodonApi {
|
|||
@Query("following") following: Boolean? = null
|
||||
): Single<SearchResult>
|
||||
|
||||
@GET("api/v2/search")
|
||||
fun searchSync(
|
||||
@Query("q") query: String?,
|
||||
@Query("type") type: String? = null,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("offset") offset: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): NetworkResult<SearchResult>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/accounts/{id}/note")
|
||||
fun updateAccountNote(
|
||||
@Path("id") accountId: String,
|
||||
@Field("comment") note: String
|
||||
): Single<Relationship>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/push/subscription")
|
||||
suspend fun subscribePushNotifications(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Field("subscription[endpoint]") endPoint: String,
|
||||
@Field("subscription[keys][p256dh]") keysP256DH: String,
|
||||
@Field("subscription[keys][auth]") keysAuth: String,
|
||||
// The "data[alerts][]" fields to enable / disable notifications
|
||||
// Should be generated dynamically from all the available notification
|
||||
// types defined in [com.keylesspalace.tusky.entities.Notification.Types]
|
||||
@FieldMap data: Map<String, Boolean>
|
||||
): NetworkResult<NotificationSubscribeResult>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/push/subscription")
|
||||
suspend fun updatePushNotificationSubscription(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@FieldMap data: Map<String, Boolean>
|
||||
): NetworkResult<NotificationSubscribeResult>
|
||||
|
||||
@DELETE("api/v1/push/subscription")
|
||||
suspend fun unsubscribePushNotifications(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
): NetworkResult<ResponseBody>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.keylesspalace.tusky.network
|
||||
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.entity.MediaUploadResult
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.http.Multipart
|
||||
|
|
@ -15,5 +16,5 @@ interface MediaUploadApi {
|
|||
suspend fun uploadMedia(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part description: MultipartBody.Part? = null
|
||||
): Result<MediaUploadResult>
|
||||
): NetworkResult<MediaUploadResult>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.receiver
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications
|
||||
import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount
|
||||
import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.android.AndroidInjection
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AndroidInjection.inject(this, context)
|
||||
if (Build.VERSION.SDK_INT < 28) return
|
||||
if (!canEnablePushNotifications(context, accountManager)) return
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val gid = when (intent.action) {
|
||||
NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> {
|
||||
val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID)
|
||||
nm.getNotificationChannel(channelId).group
|
||||
}
|
||||
NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED -> {
|
||||
intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID)
|
||||
}
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
accountManager.getAccountByIdentifier(gid)?.let { account ->
|
||||
if (isUnifiedPushNotificationEnabledForAccount(account)) {
|
||||
// Update UnifiedPush notification subscription
|
||||
GlobalScope.launch { updateUnifiedPushSubscription(context, mastodonApi, accountManager, account) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.receiver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorker
|
||||
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.android.AndroidInjection
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
import javax.inject.Inject
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
class UnifiedPushBroadcastReceiver : MessagingReceiver() {
|
||||
companion object {
|
||||
const val TAG = "UnifiedPush"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
AndroidInjection.inject(this, context)
|
||||
}
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
AndroidInjection.inject(this, context)
|
||||
Log.d(TAG, "New message received for account $instance")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val request = OneTimeWorkRequest.from(NotificationWorker::class.java)
|
||||
workManager.enqueue(request)
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
AndroidInjection.inject(this, context)
|
||||
Log.d(TAG, "Endpoint available for account $instance: $endpoint")
|
||||
accountManager.getAccountById(instance.toLong())?.let {
|
||||
// Launch the coroutine in global scope -- it is short and we don't want to lose the registration event
|
||||
// and there is no saner way to use structured concurrency in a receiver
|
||||
GlobalScope.launch { registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(context: Context, instance: String) = Unit
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
AndroidInjection.inject(this, context)
|
||||
Log.d(TAG, "Endpoint unregistered for account $instance")
|
||||
accountManager.getAccountById(instance.toLong())?.let {
|
||||
// It's fine if the account does not exist anymore -- that means it has been logged out
|
||||
GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import android.util.Log
|
|||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
|
|
|
|||
|
|
@ -62,6 +62,6 @@ object PrefKeys {
|
|||
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
|
||||
const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates"
|
||||
|
||||
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies"
|
||||
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
|
||||
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.keylesspalace.tusky.usecase
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.removeShortcut
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutUsecase @Inject constructor(
|
||||
private val context: Context,
|
||||
private val api: MastodonApi,
|
||||
private val db: AppDatabase,
|
||||
private val accountManager: AccountManager,
|
||||
private val draftHelper: DraftHelper
|
||||
) {
|
||||
|
||||
/**
|
||||
* Logs the current account out and clears all caches associated with it
|
||||
* @return true if the user is logged in with other accounts, false if it was the only one
|
||||
*/
|
||||
suspend fun logout(): Boolean {
|
||||
accountManager.activeAccount?.let { activeAccount ->
|
||||
|
||||
// invalidate the oauth token, if we have the client id & secret
|
||||
// (could be missing if user logged in with a previous version of Tusky)
|
||||
val clientId = activeAccount.clientId
|
||||
val clientSecret = activeAccount.clientSecret
|
||||
if (clientId != null && clientSecret != null) {
|
||||
api.revokeOAuthToken(
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
token = activeAccount.accessToken
|
||||
)
|
||||
}
|
||||
|
||||
// disable push notifications
|
||||
disableUnifiedPushNotificationsForAccount(context, activeAccount)
|
||||
|
||||
// disable pull notifications
|
||||
if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) {
|
||||
NotificationHelper.disablePullNotifications(context)
|
||||
}
|
||||
|
||||
// clear notification channels
|
||||
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context)
|
||||
|
||||
// remove account from local AccountManager
|
||||
val otherAccountAvailable = accountManager.logActiveAccountOut() != null
|
||||
|
||||
// clear the database - this could trigger network calls so do it last when all tokens are gone
|
||||
db.timelineDao().removeAll(activeAccount.id)
|
||||
db.conversationDao().deleteForAccount(activeAccount.id)
|
||||
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
|
||||
|
||||
// remove shortcut associated with the account
|
||||
removeShortcut(context, activeAccount)
|
||||
|
||||
return otherAccountAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.network
|
||||
package com.keylesspalace.tusky.usecase
|
||||
|
||||
import android.util.Log
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
|
|
@ -29,6 +29,7 @@ import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
|||
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.addTo
|
||||
60
app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt
Normal file
60
app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/* Copyright 2022 Tusky contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.util.Base64
|
||||
import org.bouncycastle.jce.ECNamedCurveTable
|
||||
import org.bouncycastle.jce.interfaces.ECPrivateKey
|
||||
import org.bouncycastle.jce.interfaces.ECPublicKey
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.SecureRandom
|
||||
import java.security.Security
|
||||
|
||||
object CryptoUtil {
|
||||
const val CURVE_PRIME256_V1 = "prime256v1"
|
||||
|
||||
private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
|
||||
init {
|
||||
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
|
||||
Security.addProvider(BouncyCastleProvider())
|
||||
}
|
||||
|
||||
private fun secureRandomBytes(len: Int): ByteArray {
|
||||
val ret = ByteArray(len)
|
||||
SecureRandom.getInstance("SHA1PRNG").nextBytes(ret)
|
||||
return ret
|
||||
}
|
||||
|
||||
fun secureRandomBytesEncoded(len: Int): String {
|
||||
return Base64.encodeToString(secureRandomBytes(len), BASE64_FLAGS)
|
||||
}
|
||||
|
||||
data class EncodedKeyPair(val pubkey: String, val privKey: String)
|
||||
|
||||
fun generateECKeyPair(curve: String): EncodedKeyPair {
|
||||
val spec = ECNamedCurveTable.getParameterSpec(curve)
|
||||
val gen = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME)
|
||||
gen.initialize(spec)
|
||||
val keyPair = gen.genKeyPair()
|
||||
val pubKey = keyPair.public as ECPublicKey
|
||||
val privKey = keyPair.private as ECPrivateKey
|
||||
val encodedPubKey = Base64.encodeToString(pubKey.q.getEncoded(false), BASE64_FLAGS)
|
||||
val encodedPrivKey = Base64.encodeToString(privKey.d.toByteArray(), BASE64_FLAGS)
|
||||
return EncodedKeyPair(encodedPubKey, encodedPrivKey)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import retrofit2.HttpException
|
||||
|
||||
/**
|
||||
* checks if this throwable indicates an error causes by a 4xx/5xx server response and
|
||||
* tries to retrieve the error message the server sent
|
||||
* @return the error message, or null if this is no server error or it had no error message
|
||||
*/
|
||||
fun Throwable.getServerErrorMessage(): String? {
|
||||
if (this is HttpException) {
|
||||
val errorResponse = response()?.errorBody()?.string()
|
||||
return if (!errorResponse.isNullOrBlank()) {
|
||||
try {
|
||||
JSONObject(errorResponse).getString("error")
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -47,8 +47,8 @@ sealed class StatusViewData {
|
|||
get() = status.id
|
||||
|
||||
/**
|
||||
* Specifies whether the content of this post is allowed to be collapsed or if it should show
|
||||
* all content regardless.
|
||||
* Specifies whether the content of this post is long enough to be automatically
|
||||
* collapsed or if it should show all content regardless.
|
||||
*
|
||||
* @return Whether the post is collapsible or never collapsed.
|
||||
*/
|
||||
|
|
@ -106,7 +106,7 @@ sealed class StatusViewData {
|
|||
}
|
||||
|
||||
/** Helper for Java */
|
||||
fun copyWIthCollapsed(isCollapsed: Boolean): Concrete {
|
||||
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
|
||||
return copy(isCollapsed = isCollapsed)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,92 +17,104 @@
|
|||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.Either.Left
|
||||
import com.keylesspalace.tusky.util.Either.Right
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class State(val accounts: Either<Throwable, List<TimelineAccount>>, val searchResult: List<TimelineAccount>?)
|
||||
|
||||
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
|
||||
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
|
||||
val state: Observable<State> get() = _state
|
||||
private val _state = BehaviorSubject.createDefault(State(Right(listOf()), null))
|
||||
val state: Flow<State> get() = _state
|
||||
private val _state = MutableStateFlow(State(Right(listOf()), null))
|
||||
|
||||
fun load(listId: String) {
|
||||
val state = _state.value!!
|
||||
val state = _state.value
|
||||
if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) {
|
||||
api.getAccountsInList(listId, 0).subscribe(
|
||||
{ accounts ->
|
||||
updateState { copy(accounts = Right(accounts)) }
|
||||
},
|
||||
{ e ->
|
||||
updateState { copy(accounts = Left(e)) }
|
||||
}
|
||||
).autoDispose()
|
||||
viewModelScope.launch {
|
||||
api.getAccountsInList(listId, 0).fold(
|
||||
{ accounts ->
|
||||
updateState { copy(accounts = Right(accounts)) }
|
||||
},
|
||||
{ e ->
|
||||
updateState { copy(accounts = Left(e)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addAccountToList(listId: String, account: TimelineAccount) {
|
||||
api.addCountToList(listId, listOf(account.id))
|
||||
.subscribe(
|
||||
{
|
||||
updateState {
|
||||
copy(accounts = accounts.map { it + account })
|
||||
viewModelScope.launch {
|
||||
api.addAccountToList(listId, listOf(account.id))
|
||||
.fold(
|
||||
{
|
||||
updateState {
|
||||
copy(accounts = accounts.map { it + account })
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(
|
||||
javaClass.simpleName,
|
||||
"Failed to add account to list: ${account.username}"
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(
|
||||
javaClass.simpleName,
|
||||
"Failed to add account to the list: ${account.username}"
|
||||
)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAccountFromList(listId: String, accountId: String) {
|
||||
api.deleteAccountFromList(listId, listOf(accountId))
|
||||
.subscribe(
|
||||
{
|
||||
updateState {
|
||||
copy(
|
||||
accounts = accounts.map { accounts ->
|
||||
accounts.withoutFirstWhich { it.id == accountId }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
api.deleteAccountFromList(listId, listOf(accountId))
|
||||
.fold(
|
||||
{
|
||||
updateState {
|
||||
copy(
|
||||
accounts = accounts.map { accounts ->
|
||||
accounts.withoutFirstWhich { it.id == accountId }
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(
|
||||
javaClass.simpleName,
|
||||
"Failed to remove account from list: $accountId"
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId")
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
when {
|
||||
query.isEmpty() -> updateState { copy(searchResult = null) }
|
||||
query.isBlank() -> updateState { copy(searchResult = listOf()) }
|
||||
else -> api.searchAccounts(query, null, 10, true)
|
||||
.subscribe(
|
||||
{ result ->
|
||||
updateState { copy(searchResult = result) }
|
||||
},
|
||||
{
|
||||
updateState { copy(searchResult = listOf()) }
|
||||
}
|
||||
).autoDispose()
|
||||
else -> viewModelScope.launch {
|
||||
api.searchAccounts(query, null, 10, true)
|
||||
.fold(
|
||||
{ result ->
|
||||
updateState { copy(searchResult = result) }
|
||||
},
|
||||
{
|
||||
updateState { copy(searchResult = listOf()) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(crossinline fn: State.() -> State) {
|
||||
_state.onNext(fn(_state.value!!))
|
||||
_state.value = fn(_state.value)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.core.net.toUri
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
|
|
@ -31,6 +32,7 @@ import com.keylesspalace.tusky.util.Error
|
|||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
|
|
@ -38,9 +40,6 @@ import okhttp3.MultipartBody
|
|||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -155,21 +154,7 @@ class EditProfileViewModel @Inject constructor(
|
|||
eventHub.dispatch(ProfileEditedEvent(newProfileData))
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException) {
|
||||
val errorResponse = throwable.response()?.errorBody()?.string()
|
||||
val errorMsg = if (!errorResponse.isNullOrBlank()) {
|
||||
try {
|
||||
JSONObject(errorResponse).optString("error", "")
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
saveData.postValue(Error(errorMessage = errorMsg))
|
||||
} else {
|
||||
saveData.postValue(Error())
|
||||
}
|
||||
saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage()))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,19 +16,23 @@
|
|||
|
||||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.replacedFirstWhich
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
|
||||
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
|
@ -39,86 +43,94 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
|
|||
|
||||
data class State(val lists: List<MastoList>, val loadingState: LoadingState)
|
||||
|
||||
val state: Observable<State> get() = _state
|
||||
val events: Observable<Event> get() = _events
|
||||
private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL))
|
||||
private val _events = PublishSubject.create<Event>()
|
||||
val state: Flow<State> get() = _state
|
||||
val events: Flow<Event> get() = _events
|
||||
private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL))
|
||||
private val _events = MutableSharedFlow<Event>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
fun retryLoading() {
|
||||
loadIfNeeded()
|
||||
}
|
||||
|
||||
private fun loadIfNeeded() {
|
||||
val state = _state.value!!
|
||||
val state = _state.value
|
||||
if (state.loadingState == LoadingState.LOADING || state.lists.isNotEmpty()) return
|
||||
updateState {
|
||||
copy(loadingState = LoadingState.LOADING)
|
||||
}
|
||||
|
||||
api.getLists().subscribe(
|
||||
{ lists ->
|
||||
updateState {
|
||||
copy(
|
||||
lists = lists,
|
||||
loadingState = LoadingState.LOADED
|
||||
)
|
||||
viewModelScope.launch {
|
||||
api.getLists().fold(
|
||||
{ lists ->
|
||||
updateState {
|
||||
copy(
|
||||
lists = lists,
|
||||
loadingState = LoadingState.LOADED
|
||||
)
|
||||
}
|
||||
},
|
||||
{ err ->
|
||||
updateState {
|
||||
copy(
|
||||
loadingState = if (err is IOException || err is ConnectException)
|
||||
LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ err ->
|
||||
updateState {
|
||||
copy(
|
||||
loadingState = if (err is IOException || err is ConnectException)
|
||||
LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER
|
||||
)
|
||||
}
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewList(listName: String) {
|
||||
api.createList(listName).subscribe(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists + list)
|
||||
viewModelScope.launch {
|
||||
api.createList(listName).fold(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists + list)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.CREATE_ERROR)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.CREATE_ERROR)
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun renameList(listId: String, listName: String) {
|
||||
api.updateList(listId, listName).subscribe(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists.replacedFirstWhich(list) { it.id == listId })
|
||||
viewModelScope.launch {
|
||||
api.updateList(listId, listName).fold(
|
||||
{ list ->
|
||||
updateState {
|
||||
copy(lists = lists.replacedFirstWhich(list) { it.id == listId })
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.RENAME_ERROR)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.RENAME_ERROR)
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteList(listId: String) {
|
||||
api.deleteList(listId).subscribe(
|
||||
{
|
||||
updateState {
|
||||
copy(lists = lists.withoutFirstWhich { it.id == listId })
|
||||
viewModelScope.launch {
|
||||
api.deleteList(listId).fold(
|
||||
{
|
||||
updateState {
|
||||
copy(lists = lists.withoutFirstWhich { it.id == listId })
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.DELETE_ERROR)
|
||||
}
|
||||
},
|
||||
{
|
||||
sendEvent(Event.DELETE_ERROR)
|
||||
}
|
||||
).autoDispose()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(crossinline fn: State.() -> State) {
|
||||
_state.onNext(fn(_state.value!!))
|
||||
_state.value = fn(_state.value)
|
||||
}
|
||||
|
||||
private fun sendEvent(event: Event) {
|
||||
_events.onNext(event)
|
||||
private suspend fun sendEvent(event: Event) {
|
||||
_events.emit(event)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,6 +235,19 @@
|
|||
tools:itemCount="2"
|
||||
tools:listitem="@layout/item_account_field" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountDateJoined"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
tools:text="April, 1971"
|
||||
android:textColor="@color/textColorSecondary"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
|
||||
app:layout_constraintBottom_toTopOf="@id/accountRemoveView"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountRemoveView"
|
||||
android:layout_width="match_parent"
|
||||
|
|
@ -245,7 +258,7 @@
|
|||
android:lineSpacingMultiplier="1.1"
|
||||
android:text="@string/label_remote_account"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountDateJoined"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@
|
|||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/composeBottomBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@
|
|||
android:fitsSystemWindows="true">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:id="@+id/mainCoordinatorLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.keylesspalace.tusky.MainActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="@dimen/actionbar_elevation"
|
||||
|
|
@ -75,6 +77,13 @@
|
|||
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:contentInsetStartWithNavigation="0dp"
|
||||
android:elevation="@dimen/actionbar_elevation"
|
||||
app:contentInsetStartWithNavigation="0dp"
|
||||
app:layout_scrollFlags="scroll|snap|enterAlways"
|
||||
app:navigationIcon="?attr/homeAsUpIndicator" />
|
||||
|
||||
|
|
@ -27,8 +27,9 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:tabGravity="fill"
|
||||
app:tabMaxWidth="0dp"
|
||||
app:tabMode="fixed"
|
||||
app:tabTextAppearance="@style/TuskyTabAppearance"/>
|
||||
app:tabTextAppearance="@style/TuskyTabAppearance" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
|
|
@ -38,6 +39,6 @@
|
|||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<include layout="@layout/item_status_bottom_sheet"/>
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -1,48 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="8dp">
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="42dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:contentDescription="@null"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:foregroundGravity="center_vertical"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/avatar_default" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_toEndOf="@id/avatar"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
<ImageView
|
||||
android:id="@+id/avatarBadge"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/profile_badge_bot_text"
|
||||
android:src="@drawable/bot_badge"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:textStyle="normal|bold"
|
||||
tools:text="Conny Duck" />
|
||||
<TextView
|
||||
android:id="@+id/displayName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constraintBottom_toTopOf="@id/username"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
||||
app:layout_constraintTop_toTopOf="@id/avatar"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="Display name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/username"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
tools:text="\@ConnyDuck" />
|
||||
<TextView
|
||||
android:id="@+id/username"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/displayName"
|
||||
tools:text="\@username" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<View xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/status_divider" />
|
||||
|
|
@ -5,24 +5,24 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
tools:ignore="UseCompoundDrawables">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/preview"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:contentDescription="@null"
|
||||
android:padding="4dp" />
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/shortcode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/hashtag"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
android:textStyle="normal|bold"
|
||||
app:drawableStartCompat="@drawable/ic_list"
|
||||
app:drawableTint="?attr/iconColor" />
|
||||
tools:text="#Tusky" />
|
||||
|
|
|
|||
|
|
@ -319,6 +319,16 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_poll_description"
|
||||
app:srcCompat="@drawable/ic_reply_24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_replies"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="@id/status_reply"
|
||||
app:layout_constraintStart_toEndOf="@id/status_reply"
|
||||
app:layout_constraintTop_toTopOf="@id/status_reply"
|
||||
android:textAlignment="viewEnd"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<at.connyduck.sparkbutton.SparkButton
|
||||
android:id="@+id/status_inset"
|
||||
android:layout_width="30dp"
|
||||
|
|
|
|||
|
|
@ -269,7 +269,6 @@
|
|||
<string name="add_account_description">إضافة حساب ماستدون جديد</string>
|
||||
<string name="action_lists">القوائم</string>
|
||||
<string name="title_lists">القوائم</string>
|
||||
<string name="title_list_timeline">الخط الزمني للقائمة</string>
|
||||
<string name="error_create_list">لا يمكن إنشاء قائمة</string>
|
||||
<string name="error_rename_list">لا يمكن إعادة تسمية القائمة</string>
|
||||
<string name="error_delete_list">لا يمكن حذف القائمة</string>
|
||||
|
|
|
|||
|
|
@ -151,7 +151,6 @@
|
|||
<string name="error_delete_list">Списъкът не можа да се изтрие</string>
|
||||
<string name="error_create_list">Списъкът не можа да се създаде</string>
|
||||
<string name="error_rename_list">Списъкът не можа да се преименува</string>
|
||||
<string name="title_list_timeline">Списъчна емисия</string>
|
||||
<string name="title_lists">Списъци</string>
|
||||
<string name="action_lists">Списъци</string>
|
||||
<string name="add_account_description">Добавяне на нов Mastodon акаунт</string>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@
|
|||
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>
|
||||
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
|
||||
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
|
||||
<string name="title_list_timeline">তালিকা টাইমলাইনে রাখুন</string>
|
||||
<string name="title_lists">তালিকাসমূহ</string>
|
||||
<string name="action_lists">তালিকাসমূহ</string>
|
||||
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
|
||||
|
|
|
|||
|
|
@ -275,7 +275,6 @@
|
|||
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
|
||||
<string name="action_lists">তালিকাসমূহ</string>
|
||||
<string name="title_lists">তালিকাসমূহ</string>
|
||||
<string name="title_list_timeline">তালিকা টাইমলাইনে রাখুন</string>
|
||||
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
|
||||
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
|
||||
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>
|
||||
|
|
|
|||
|
|
@ -275,7 +275,6 @@
|
|||
<string name="add_account_description">Afegir un compte de Mastodont</string>
|
||||
<string name="action_lists">Llistes</string>
|
||||
<string name="title_lists">Llistes</string>
|
||||
<string name="title_list_timeline">Cronologia de la llista</string>
|
||||
<string name="error_create_list">És impossible crear la llista</string>
|
||||
<string name="error_rename_list">Impossible reanomenar la llista</string>
|
||||
<string name="error_delete_list">És impossible suprimir la llista</string>
|
||||
|
|
|
|||
|
|
@ -403,7 +403,6 @@
|
|||
<string name="error_delete_list">نەیتوانی لیستەکە بسڕێتەوە</string>
|
||||
<string name="error_rename_list">نەیتوانی ناوی لیست بنووسرێ</string>
|
||||
<string name="error_create_list">نەیتوانی لیست دروست بکات</string>
|
||||
<string name="title_list_timeline">لیستی تایم لاین</string>
|
||||
<string name="title_lists">لیستەکان</string>
|
||||
<string name="action_lists">لیستەکان</string>
|
||||
<string name="add_account_description">زیادکردنی ئەژمێری ماتۆدۆنی نوێ</string>
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@
|
|||
<string name="pref_default_post_privacy">Výchozí soukromí příspěvků</string>
|
||||
<string name="pref_default_media_sensitivity">Vždy označovat média jako citlivá</string>
|
||||
<string name="pref_publishing">Publikování (synchronizováno se serverem)</string>
|
||||
<string name="pref_failed_to_sync">Nepodařilo se synchronizovsat nastavení</string>
|
||||
<string name="pref_failed_to_sync">Nepodařilo se synchronizovat nastavení</string>
|
||||
<string name="post_privacy_public">Veřejné</string>
|
||||
<string name="post_privacy_unlisted">Neuvedené</string>
|
||||
<string name="post_privacy_followers_only">Pouze pro sledující</string>
|
||||
|
|
@ -274,7 +274,6 @@
|
|||
<string name="add_account_description">Přidat nový účet Mastodon</string>
|
||||
<string name="action_lists">Seznamy</string>
|
||||
<string name="title_lists">Seznamy</string>
|
||||
<string name="title_list_timeline">Časová osa seznamu</string>
|
||||
<string name="error_create_list">Nelze vytvořit seznam</string>
|
||||
<string name="error_rename_list">Nelze přejmenovat seznam</string>
|
||||
<string name="error_delete_list">Nelze smazat seznam</string>
|
||||
|
|
@ -484,4 +483,12 @@
|
|||
<string name="pref_title_confirm_reblogs">Zobrazit dialogové okno s potvrzením při boostování</string>
|
||||
<string name="notification_subscription_format">%s právě vydal</string>
|
||||
<string name="title_announcements">Oznámení</string>
|
||||
<string name="title_login">Přihlášení</string>
|
||||
<string name="notification_sign_up_format">%s se zaregistroval</string>
|
||||
<string name="title_migration_relogin">Přihlaste se znovu pro oznámení</string>
|
||||
<string name="error_could_not_load_login_page">Nepodařilo se načíst stránku přihlášení.</string>
|
||||
<string name="drafts_post_failed_to_send">Tento příspěvek se nepodařilo poslat!</string>
|
||||
<string name="error_loading_account_details">Nepodařilo se načíst detaily účtu</string>
|
||||
<string name="drafts_failed_loading_reply">Nepodařilo se načíst informace o odpovědi</string>
|
||||
<string name="error_image_edit_failed">Obrázek se nepodařilo upravit.</string>
|
||||
</resources>
|
||||
|
|
@ -236,7 +236,6 @@
|
|||
<string name="add_account_description">Ychwanegu cyfrif Mastodon newydd</string>
|
||||
<string name="action_lists">Rhestri</string>
|
||||
<string name="title_lists">Rhestri</string>
|
||||
<string name="title_list_timeline">Amserlen rhestri</string>
|
||||
<string name="compose_active_account_description">Yn postio â chyfrif %1$s</string>
|
||||
<string name="error_failed_set_caption">Methu gosod pennawd</string>
|
||||
<string name="action_set_caption">Pennu pennawd</string>
|
||||
|
|
|
|||
|
|
@ -256,7 +256,6 @@
|
|||
<string name="add_account_description">Neues Mastodon-Konto hinzufügen</string>
|
||||
<string name="action_lists">Listen</string>
|
||||
<string name="title_lists">Listen</string>
|
||||
<string name="title_list_timeline">Liste</string>
|
||||
<string name="action_create_list">Liste erstellen</string>
|
||||
<string name="action_rename_list">Liste umbenennen</string>
|
||||
<string name="action_delete_list">Liste löschen</string>
|
||||
|
|
@ -537,4 +536,9 @@
|
|||
<string name="title_login">Anmelden</string>
|
||||
<string name="error_could_not_load_login_page">Die Anmeldeseite konnte nicht geladen werden.</string>
|
||||
<string name="notification_update_name">Beitragsbearbeitungen</string>
|
||||
<string name="title_migration_relogin">Neuanmeldung für Push-Benachrichtigungen</string>
|
||||
<string name="action_dismiss">Ablehnen</string>
|
||||
<string name="dialog_push_notification_migration_other_accounts">Du hast dich erneut in dein aktuelles Konto eingeloggt, um Tusky die Genehmigung für Push-Abonnements zu erteilen. Du hast jedoch noch andere Konten, die nicht auf diese Weise migriert wurden. Wechsel zu diesen Konten und melde dich nacheinander neu an, um die Unterstützung für UnifiedPush-Benachrichtigungen zu aktivieren.</string>
|
||||
<string name="dialog_push_notification_migration">Um Push-Benachrichtigungen über UnifiedPush verwenden zu können, benötigt Tusky die Erlaubnis, Benachrichtigungen auf Ihrem Mastodon-Server zu abonnieren. Dies erfordert eine erneute Anmeldung, um die Tusky gewährten OAuth-Bereiche zu ändern. Wenn du die Option zum erneuten Einloggen hier oder in den Kontoeinstellungen verwendest, bleiben alle deine lokalen Entwürfe und der Cache erhalten.</string>
|
||||
<string name="tips_push_notification_migration">Melde alle Konten neu an, um die Unterstützung für Push-Benachrichtigungen zu aktivieren.</string>
|
||||
</resources>
|
||||
|
|
@ -271,7 +271,6 @@
|
|||
<string name="add_account_description">Aldoni novan Mastodon konton</string>
|
||||
<string name="action_lists">Listoj</string>
|
||||
<string name="title_lists">Listoj</string>
|
||||
<string name="title_list_timeline">Tempolinio de la listo</string>
|
||||
<string name="error_create_list">Ne povis krei la liston</string>
|
||||
<string name="error_rename_list">Ne povis ŝanĝi la nomon de la listo</string>
|
||||
<string name="error_delete_list">Ne povis forigi la liston</string>
|
||||
|
|
|
|||
|
|
@ -251,7 +251,6 @@
|
|||
<string name="add_account_description">Añadir cuenta de Mastodon</string>
|
||||
<string name="action_lists">Listas</string>
|
||||
<string name="title_lists">Listas</string>
|
||||
<string name="title_list_timeline">Cronología de lista</string>
|
||||
<string name="compose_active_account_description">Publicando con la cuenta %1$s</string>
|
||||
<string name="error_failed_set_caption">Error al añadir leyenda</string>
|
||||
<plurals name="hint_describe_for_visually_impaired">
|
||||
|
|
|
|||
|
|
@ -235,7 +235,6 @@
|
|||
<string name="add_account_description">Mastodon kontua gehitu</string>
|
||||
<string name="action_lists">Zerrendak</string>
|
||||
<string name="title_lists">Zerrendak</string>
|
||||
<string name="title_list_timeline">Zerrenda denbora-lerroa</string>
|
||||
<string name="compose_active_account_description">%1$s kontuarekin tut egiten</string>
|
||||
<string name="error_failed_set_caption">Akatsa deskribapena eranstean</string>
|
||||
<plurals name="hint_describe_for_visually_impaired">
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@
|
|||
<string name="error_authorization_unknown">خطای احراز هویت ناشناختهای رخ داد.</string>
|
||||
<string name="error_authorization_denied">احراز هویت رد شد.</string>
|
||||
<string name="error_retrieving_oauth_token">دریافت ژتون ورود شکست خورد.</string>
|
||||
<string name="error_compose_character_limit">وضعیت خیلی طولانی است!</string>
|
||||
<string name="error_compose_character_limit">فرسته خیلی طولانی است!</string>
|
||||
<string name="error_image_upload_size">پرونده باید کمتر از ۸ مگابایت باشد.</string>
|
||||
<string name="error_video_upload_size">پرونده ویدئویی باید کمتر از ۴۰ مگابایت باشد.</string>
|
||||
<string name="error_media_upload_type">این گونهٔ پرونده نمیتواند بارگذاری شود.</string>
|
||||
<string name="error_media_upload_opening">این پرونده نتوانست گشوده شود.</string>
|
||||
<string name="error_media_upload_permission">نیاز به اجازهٔ خواندن رسانه است.</string>
|
||||
<string name="error_media_download_permission">نیاز به اجازهٔ ذخیرهٔ رسانه است.</string>
|
||||
<string name="error_media_upload_image_or_video">تصاویر و فیلمها هر دو نمیتوانند به یک وضعیت ضمیمه شوند.</string>
|
||||
<string name="error_media_upload_image_or_video">تصاویر و فیلمها نمیتوانند به یک فرسته پیوست شوند.</string>
|
||||
<string name="error_media_upload_sending">بارگذاری شکست خورد.</string>
|
||||
<string name="error_sender_account_gone">خطای فرستادن بوق.</string>
|
||||
<string name="error_sender_account_gone">خطای فرستادن فرسته.</string>
|
||||
<string name="title_home">خانه</string>
|
||||
<string name="title_notifications">آگاهیها</string>
|
||||
<string name="title_public_local">محلّی</string>
|
||||
<string name="title_public_federated">همگانی</string>
|
||||
<string name="title_view_thread">بوق</string>
|
||||
<string name="title_posts">فرسته</string>
|
||||
<string name="title_view_thread">رشته</string>
|
||||
<string name="title_posts">فرستهها</string>
|
||||
<string name="title_posts_with_replies">با پاسخ</string>
|
||||
<string name="title_follows">دنبال شونده</string>
|
||||
<string name="title_followers">پیگیر</string>
|
||||
|
|
@ -41,10 +41,10 @@
|
|||
<string name="post_content_warning_show_more">نمایش بیشتر</string>
|
||||
<string name="post_content_warning_show_less">نمایش کمتر</string>
|
||||
<string name="post_content_show_more">گسترش</string>
|
||||
<string name="post_content_show_less">بستن</string>
|
||||
<string name="post_content_show_less">جمع کردن</string>
|
||||
<string name="footer_empty">اینجا هیچچیز نیست. برای تازهسازی، به پایین بکشید!</string>
|
||||
<string name="notification_reblog_format">%s بوقتان را تقویت کرد</string>
|
||||
<string name="notification_favourite_format">%s بوقتان را برگزید</string>
|
||||
<string name="notification_reblog_format">%s فرستهتان را تقویت کرد</string>
|
||||
<string name="notification_favourite_format">%s فرستهتان را برگزید</string>
|
||||
<string name="notification_follow_format">%s پیگیرتان شد</string>
|
||||
<string name="report_username_format">گزارش @%s</string>
|
||||
<string name="report_comment_hint">نظرهای اضافی؟</string>
|
||||
|
|
@ -93,13 +93,13 @@
|
|||
<string name="action_reject">رد</string>
|
||||
<string name="action_search">جستوجو</string>
|
||||
<string name="action_access_drafts">پیشنویسها</string>
|
||||
<string name="action_toggle_visibility">نمایانی بوق</string>
|
||||
<string name="action_toggle_visibility">نمایانی فرسته</string>
|
||||
<string name="action_content_warning">هشدار محتوا</string>
|
||||
<string name="action_emoji_keyboard">صفحهکلید اموجی</string>
|
||||
<string name="download_image">درحال بارگیری %1$s</string>
|
||||
<string name="action_copy_link">رونوشت از پیوند</string>
|
||||
<string name="send_post_link_to">همرسانی نشانی بوق با…</string>
|
||||
<string name="send_post_content_to">همرسانی بوق با…</string>
|
||||
<string name="send_post_link_to">همرسانی نشانی فرسته با…</string>
|
||||
<string name="send_post_content_to">همرسانی فرسته با…</string>
|
||||
<string name="send_media_to">همرسانی رسانه با…</string>
|
||||
<string name="confirmation_reported">فرستاده شد!</string>
|
||||
<string name="confirmation_unblocked">کاربرنامسدود شد</string>
|
||||
|
|
@ -130,7 +130,7 @@
|
|||
<string name="dialog_download_image">بارگیری</string>
|
||||
<string name="dialog_message_cancel_follow_request">درخواست دنبال کردن را لغو میکنید؟</string>
|
||||
<string name="dialog_unfollow_warning">ناپیگیری این حساب؟</string>
|
||||
<string name="dialog_delete_post_warning">حذف این بوق؟</string>
|
||||
<string name="dialog_delete_post_warning">حذف این فرسته؟</string>
|
||||
<string name="visibility_public">عمومی: فرستادن به خط زمانیهای عمومی</string>
|
||||
<string name="visibility_unlisted">فهرستنشده: نشان ندادن در خط زمانیهای عمومی</string>
|
||||
<string name="visibility_private">تنها دنبالکنندگان:پست فقط به دنبالکنندگان</string>
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
<string name="pref_title_browser_settings">مرورگر</string>
|
||||
<string name="pref_title_custom_tabs">استفاده از زبانههای سفارشی کروم</string>
|
||||
<string name="pref_title_hide_follow_button">نهفتن دکمهٔ ایجاد، هنگام پیمایش</string>
|
||||
<string name="pref_title_post_filter">فیلتر کردن خط زمانی</string>
|
||||
<string name="pref_title_post_filter">پالایش خط زمانی</string>
|
||||
<string name="pref_title_post_tabs">زبانهها</string>
|
||||
<string name="pref_title_show_boosts">نمایش تقویتها</string>
|
||||
<string name="pref_title_show_replies">نمایش پاسخها</string>
|
||||
|
|
@ -173,10 +173,10 @@
|
|||
<string name="post_privacy_public">عمومی</string>
|
||||
<string name="post_privacy_unlisted">فهرستنشده</string>
|
||||
<string name="post_privacy_followers_only">فقط پیگیران</string>
|
||||
<string name="pref_post_text_size">اندازهٔ متن وضعیت</string>
|
||||
<string name="pref_post_text_size">اندازهٔ متن فرسته</string>
|
||||
<string name="post_text_size_smallest">کوچکترین</string>
|
||||
<string name="post_text_size_small">کوچک</string>
|
||||
<string name="post_text_size_medium">متوسط</string>
|
||||
<string name="post_text_size_medium">میانه</string>
|
||||
<string name="post_text_size_large">بزرگ</string>
|
||||
<string name="post_text_size_largest">بزرگترین</string>
|
||||
<string name="notification_mention_name">اشارههای جدید</string>
|
||||
|
|
@ -184,9 +184,9 @@
|
|||
<string name="notification_follow_name">پیگیران جدید</string>
|
||||
<string name="notification_follow_description">آگاهیها دربارهٔ پیگیران جدید</string>
|
||||
<string name="notification_boost_name">تقویتها</string>
|
||||
<string name="notification_boost_description">آگاهیها هنگام تقویت شدن بوقهایتان</string>
|
||||
<string name="notification_boost_description">آگاهیها هنگام تقویت فرستههایتان</string>
|
||||
<string name="notification_favourite_name">برگزیدنها</string>
|
||||
<string name="notification_favourite_description">آگاهیها هنگام برگزیده شدن بوقهایتان</string>
|
||||
<string name="notification_favourite_description">آگاهیها هنگام برگزیده شدن فرستههایتان</string>
|
||||
<string name="notification_mention_format">%s به شما اشاره کرد</string>
|
||||
<string name="notification_summary_large">%1$s، %2$s، %3$s و %4$d دیگر</string>
|
||||
<string name="notification_summary_medium">%1$s، %2$s و %3$s</string>
|
||||
|
|
@ -209,8 +209,8 @@
|
|||
<string name="about_bug_feature_request_site">گزارش مشکلات و درخواست ویژگیها:
|
||||
\n https://git.chinwag.org/chinwag/chinwag-android/issues</string>
|
||||
<string name="about_tusky_account">نمایهٔ تاسکی</string>
|
||||
<string name="post_share_content">همرسانی محتوای بوق</string>
|
||||
<string name="post_share_link">همرسانی پیوند بوق</string>
|
||||
<string name="post_share_content">همرسانی محتوای فرسته</string>
|
||||
<string name="post_share_link">همرسانی پیوند فرسته</string>
|
||||
<string name="post_media_images">تصویرها</string>
|
||||
<string name="post_media_video">ویدیو</string>
|
||||
<string name="state_follow_requested">تقاضای پیگیری شد</string>
|
||||
|
|
@ -229,7 +229,6 @@
|
|||
<string name="add_account_description">افزودن حساب ماستودون جدید</string>
|
||||
<string name="action_lists">فهرستها</string>
|
||||
<string name="title_lists">فهرستها</string>
|
||||
<string name="title_list_timeline">خط زمانی فهرست</string>
|
||||
<string name="compose_active_account_description">در حال فرستادن با حساب %1$s</string>
|
||||
<string name="error_failed_set_caption">شکست در تنظیم عنوان</string>
|
||||
<plurals name="hint_describe_for_visually_impaired">
|
||||
|
|
@ -241,19 +240,19 @@
|
|||
<string name="lock_account_label">قفل حساب</string>
|
||||
<string name="lock_account_label_description">لازم است پیگیران را دستی تأیید کنید</string>
|
||||
<string name="compose_save_draft">ذخیرهٔ پیشنویس؟</string>
|
||||
<string name="send_post_notification_title">در حال فرستادن بوق…</string>
|
||||
<string name="send_post_notification_error_title">خطای فرستادن بوق</string>
|
||||
<string name="send_post_notification_channel_name">در حال فرستادن بوقها</string>
|
||||
<string name="send_post_notification_title">فرستادن فرسته…</string>
|
||||
<string name="send_post_notification_error_title">خطا در فرستادن فرسته</string>
|
||||
<string name="send_post_notification_channel_name">فرستادن فرستهها</string>
|
||||
<string name="send_post_notification_cancel_title">فرستادن لغو شد</string>
|
||||
<string name="send_post_notification_saved_content">رونوشتی از بوق در پیشنویسهایتان ذخیره شد</string>
|
||||
<string name="send_post_notification_saved_content">رونوشتی از فرسته در پیشنویسهایتان ذخیره شد</string>
|
||||
<string name="action_compose_shortcut">ایجاد</string>
|
||||
<string name="error_no_custom_emojis">نمونهتان %s هیچ اموجی سفارشیای ندارد</string>
|
||||
<string name="emoji_style">سبک اموجی</string>
|
||||
<string name="system_default">پیشگزیدهٔ سامانه</string>
|
||||
<string name="download_fonts">نخست باید این مجموعههای اموجی را بارگیری کنید</string>
|
||||
<string name="performing_lookup_title">در حال جستوجو…</string>
|
||||
<string name="expand_collapse_all_posts">گسترده/جمع کردن تمام وضعیتها</string>
|
||||
<string name="action_open_post">گشودن بوق</string>
|
||||
<string name="expand_collapse_all_posts">گسترش/جمع کردن تمام فرستهها</string>
|
||||
<string name="action_open_post">گشودن فرسته</string>
|
||||
<string name="restart_required">نیاز به آغاز دوبارهٔ کاره</string>
|
||||
<string name="restart_emoji">برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید</string>
|
||||
<string name="later">بعداً</string>
|
||||
|
|
@ -279,7 +278,7 @@
|
|||
<string name="error_network">یک خطای شبکه رخ داد! لطفا اتصال خود را بررسی و دوباره تلاش کنید!</string>
|
||||
<string name="title_direct_messages">پیامهای مستقیم</string>
|
||||
<string name="title_tab_preferences">زبانهها</string>
|
||||
<string name="title_posts_pinned">سنجاقشده</string>
|
||||
<string name="title_posts_pinned">سنجاق شده</string>
|
||||
<string name="title_domain_mutes">دامنههای نهفته</string>
|
||||
<string name="post_username_format">\@%s</string>
|
||||
<string name="message_empty">اینجا هیچچیزی نیست.</string>
|
||||
|
|
@ -305,7 +304,7 @@
|
|||
<string name="download_media">بارگیری رسانه</string>
|
||||
<string name="downloading_media">در حال بارگیری رسانه</string>
|
||||
<string name="confirmation_domain_unmuted">%s نانهفته</string>
|
||||
<string name="dialog_redraft_post_warning">میخواهید این بوق را پاک و بازنویسی کنید؟</string>
|
||||
<string name="dialog_redraft_post_warning">حذف و بازنویسی این فرسته؟</string>
|
||||
<string name="mute_domain_warning_dialog_ok">نهفتن تمام دامنه</string>
|
||||
<string name="pref_title_notification_filter_poll">پایان نظرسنجیها</string>
|
||||
<string name="pref_title_timeline_filters">پالایهها</string>
|
||||
|
|
@ -321,7 +320,7 @@
|
|||
<string name="abbreviated_hours_ago">%d ساعت</string>
|
||||
<string name="abbreviated_minutes_ago">%d دقیقه</string>
|
||||
<string name="abbreviated_seconds_ago">%d ثانیه</string>
|
||||
<string name="pref_title_alway_open_spoiler">گسترش همیشگی بوقهای علامتخورده با هشدار محتوا</string>
|
||||
<string name="pref_title_alway_open_spoiler">گسترش همیشگی فرستههای علامتخورده با هشدار محتوا</string>
|
||||
<string name="pref_title_public_filter_keywords">خط زمانیهای عمومی</string>
|
||||
<string name="pref_title_thread_filter_keywords">گفتوگوها</string>
|
||||
<string name="filter_addition_dialog_title">افزودن پالایه</string>
|
||||
|
|
@ -361,8 +360,8 @@
|
|||
</plurals>
|
||||
<string name="description_post_media">رسانه: %s</string>
|
||||
<string name="description_post_cw">هشدار محتوا: %s</string>
|
||||
<string name="description_post_media_no_description_placeholder">بدون هیچ توضیحی</string>
|
||||
<string name="description_post_reblogged">بازبوقیده</string>
|
||||
<string name="description_post_media_no_description_placeholder">بدون شرح</string>
|
||||
<string name="description_post_reblogged">تقویت شده</string>
|
||||
<string name="description_post_favourited">برگزیده</string>
|
||||
<string name="description_visiblity_public">عمومی</string>
|
||||
<string name="description_visiblity_unlisted">فهرستنشده</string>
|
||||
|
|
@ -374,7 +373,7 @@
|
|||
<string name="notifications_clear">پاکسازی</string>
|
||||
<string name="notifications_apply_filter">پالایش</string>
|
||||
<string name="filter_apply">اعمال</string>
|
||||
<string name="compose_shortcut_long_label">ایجاد بوق</string>
|
||||
<string name="compose_shortcut_long_label">ایجاد فرسته</string>
|
||||
<string name="compose_shortcut_short_label">ایجاد</string>
|
||||
<string name="notification_clear_text">مطمئنید میخواهید تمام آگاهیهایتان را برای همیشه پاک کنید؟</string>
|
||||
<string name="poll_info_time_absolute">پایان در %s</string>
|
||||
|
|
@ -401,7 +400,7 @@
|
|||
<string name="hint_additional_info">نظرهای اضافی</string>
|
||||
<string name="report_remote_instance">هدایت به %s</string>
|
||||
<string name="failed_report">شکست در گزارش</string>
|
||||
<string name="failed_fetch_posts">شکست در واکشی وضعیتها</string>
|
||||
<string name="failed_fetch_posts">شکست در واکشی فرستهها</string>
|
||||
<string name="title_accounts">حسابها</string>
|
||||
<string name="failed_search">شکست در جستوجو</string>
|
||||
<string name="pref_title_show_notifications_filter">نمایش پالایهٔ آگاهیها</string>
|
||||
|
|
@ -417,10 +416,10 @@
|
|||
<string name="poll_allow_multiple_choices">گزینههای چندگانه</string>
|
||||
<string name="poll_new_choice_hint">گزینهٔ %d</string>
|
||||
<string name="edit_poll">ویرایش</string>
|
||||
<string name="title_scheduled_posts">بوقهای زمانبسته</string>
|
||||
<string name="title_scheduled_posts">فرستههای زمانبسته</string>
|
||||
<string name="action_edit">ویرایش</string>
|
||||
<string name="action_access_scheduled_posts">بوقهای زمانبسته</string>
|
||||
<string name="action_schedule_post">بوق زمانبسته</string>
|
||||
<string name="action_access_scheduled_posts">فرستههای زمانبسته</string>
|
||||
<string name="action_schedule_post">فرستهٔ زمانبسته</string>
|
||||
<string name="action_reset_schedule">بازنشانی</string>
|
||||
<string name="mute_domain_warning">مطمئنید میخواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچیک از خط زمانیها یا در آگاهیهایتان نخواهید دید. پیگیرانتان از آن دامنه، برداشته خواهند شد.</string>
|
||||
<string name="filter_dialog_whole_word_description">هنگامی که کلیدواژه یا عبارت، فقط حروفعددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد</string>
|
||||
|
|
@ -438,7 +437,7 @@
|
|||
<string name="select_list_title">گزینش فهرست</string>
|
||||
<string name="list">فهرست</string>
|
||||
<string name="no_drafts">هیچ پیشنویسی ندارید.</string>
|
||||
<string name="no_scheduled_posts">هیچ وضعیت زمانبستهای ندارید.</string>
|
||||
<string name="no_scheduled_posts">هیچ فرستهٔ زمانبستهای ندارید.</string>
|
||||
<string name="warning_scheduling_interval">ماستودون، بازهٔ زمانبندیای با کمینهٔ ۵ دقیقه دارد.</string>
|
||||
<string name="pref_title_confirm_reblogs">نمایش گفتوگوی تأیید، پیش از تقویت</string>
|
||||
<string name="pref_title_show_cards_in_timelines">پیشنمایش پیوندها در خطزمانیها</string>
|
||||
|
|
@ -483,7 +482,7 @@
|
|||
<string name="action_unsubscribe_account">عدم اشتراک</string>
|
||||
<string name="action_subscribe_account">اشتراک</string>
|
||||
<string name="draft_deleted">پیشنویس حذف شد</string>
|
||||
<string name="drafts_post_failed_to_send">فرستادن این بوق شکست خورد!</string>
|
||||
<string name="drafts_post_failed_to_send">فرستادن این فرسته شکست خورد!</string>
|
||||
<string name="wellbeing_hide_stats_profile">نهفتن آمار کمی روی نمایهها</string>
|
||||
<string name="wellbeing_hide_stats_posts">نهفتن آمار کمی روی فرستهها</string>
|
||||
<string name="limit_notifications">محدود کردن آگاهیهای خطزمانی</string>
|
||||
|
|
@ -492,17 +491,17 @@
|
|||
<string name="label_duration">طول</string>
|
||||
<string name="post_media_attachments">پیوستها</string>
|
||||
<string name="post_media_audio">صدا</string>
|
||||
<string name="notification_subscription_description">آگاهیها هنگام انتشار بوقی جدید از کسی که مشترکش هستید</string>
|
||||
<string name="notification_subscription_name">بوقهای جدید</string>
|
||||
<string name="notification_subscription_description">آگاهیها هنگام انتشار فرستهای جدید از کسی که پیمیگیرید</string>
|
||||
<string name="notification_subscription_name">فرستههای جدید</string>
|
||||
<string name="pref_title_animate_custom_emojis">اموجیهای شخصی متحرّک</string>
|
||||
<string name="pref_title_notification_filter_subscriptions">کسی که مشترکش شدهام، بوقی جدید منتشر کرد</string>
|
||||
<string name="pref_title_notification_filter_subscriptions">کسی که پیمیگیرم، فرستهای جدید منتشر کرد</string>
|
||||
<string name="notification_subscription_format">%s چیزی فرستاد</string>
|
||||
<string name="drafts_post_reply_removed">بوقی که پاسخی به آن را پیشنویس کردید، برداشته شده</string>
|
||||
<string name="drafts_post_reply_removed">فرستهای که پاسخی به آن را پیشنویس کردید، برداشته شده</string>
|
||||
<string name="drafts_failed_loading_reply">شکست در بار کردن اطّلاعات پاسخ</string>
|
||||
<string name="wellbeing_mode_notice">برخی اطّلاعات که ممکن است روی سلامتی ذهنیتان تأثیر بگذارد، پنهان خواهند شد. همچون:
|
||||
\n
|
||||
\n - آگاهیهای برگزیدن، تقویت و پیگیری
|
||||
\n - شمار برگزیدن و تقویت بوقها
|
||||
\n - شمار برگزیدن و تقویت فرستهها
|
||||
\n - آمار پیگیر و فرسته روی نمایهها
|
||||
\n
|
||||
\n فرستادن آگاهیها تأثیر نمیپذیرد، ولی میتوانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید.</string>
|
||||
|
|
@ -515,4 +514,34 @@
|
|||
<string name="follow_requests_info">با این که حسابتان قفل نیست، کارکنان %1$s فکر کردند ممکن است بخواهید درخواستهای پیگیری از این حسابها را دستی بازبینی کنید.</string>
|
||||
<string name="dialog_delete_conversation_warning">حذف این گفتوگو؟</string>
|
||||
<string name="action_delete_conversation">حذف گفتوگو</string>
|
||||
<string name="account_date_joined">در %1$s پیوست</string>
|
||||
<string name="title_login">ورود</string>
|
||||
<string name="notification_sign_up_format">%s ثبتنام کرد</string>
|
||||
<string name="pref_title_confirm_favourites">نمایش گفتوگوی تأیید پیش از برگزیدن</string>
|
||||
<string name="tusky_compose_post_quicksetting_label">ایجاد فرسته</string>
|
||||
<string name="tips_push_notification_migration">ورود دوباره به تمامی حسابها برای به کار انداختن پشتیبانی آگاهیهای ارسالی.</string>
|
||||
<string name="notification_update_description">آگاهیها هنگام ویرایش فرستههایی که با آنها تعامل داشتهاید</string>
|
||||
<string name="action_unbookmark">برداشن نشانک</string>
|
||||
<string name="dialog_push_notification_migration_other_accounts">برای اعطای اجازهٔ اشتراک آگاهیهای ارسالی ، دوباره به حسابتان وارد شدید. با این حال هنوز حسابهایی دیگر دارید که اینگونه مهاجرت داده نشدهاند. به آنها رفته و برای به کار انداختن پشتیبانی آگاهیهای UnifiedPush یکییکی دوباره وارد شوید.</string>
|
||||
<string name="action_logout_confirm">مطمئنید که میخواهید از حساب %1$s خارج شوید؟</string>
|
||||
<string name="duration_14_days">۱۴ روز</string>
|
||||
<string name="duration_30_days">۳۰ روز</string>
|
||||
<string name="duration_60_days">۶۰ روز</string>
|
||||
<string name="duration_90_days">۹۰ روز</string>
|
||||
<string name="duration_365_days">۳۶۵ روز</string>
|
||||
<string name="duration_180_days">۱۸۰ روز</string>
|
||||
<string name="status_count_one_plus">۱+</string>
|
||||
<string name="dialog_push_notification_migration">تاسکی برای استفاده از آگاهیهای ارسالی با UnifiedPush نیاز به اجازهٔ اشتراک آگاهیها روی کارساز ماستودنتان دارد. این کار نیازمند ورود دوباره برای تغییر حوزههای OAuth اعطایی به تاسکی است. استفاده از گزینهٔ ورود دوباره در اینجا یا در ترجیحات حساب، تمامی انبارهها و پیشنویسهای محلیتان را نگه خواهد داشت.</string>
|
||||
<string name="error_could_not_load_login_page">نتوانست صفحهٔ ورود را بار کند.</string>
|
||||
<string name="pref_title_notification_filter_sign_ups">کسی ثبتنام کرد</string>
|
||||
<string name="notification_update_name">ویرایشهای فرسته</string>
|
||||
<string name="action_edit_image">ویرایش تصویر</string>
|
||||
<string name="notification_update_format">%s فرستهاش را ویراست</string>
|
||||
<string name="pref_title_notification_filter_updates">فرستهای که با آن تعامل داشتهام ویرایش شده</string>
|
||||
<string name="notification_sign_up_name">ثبتنامها</string>
|
||||
<string name="notification_sign_up_description">آگاهیها دربارهٔ کاربران جدید</string>
|
||||
<string name="title_migration_relogin">ورود دوباره برای آگاهیهای ارسالی</string>
|
||||
<string name="action_dismiss">رد کردن</string>
|
||||
<string name="action_details">جزییات</string>
|
||||
<string name="saving_draft">ذخیرهٔ پیشنویس…</string>
|
||||
</resources>
|
||||
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