Merge tag 'v19.0' into develop

This commit is contained in:
Mike Barnes 2023-07-30 17:50:50 +10:00
commit eeeb6a8599
160 changed files with 6469 additions and 1305 deletions

View file

@ -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"

View file

@ -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 {

View file

@ -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)
}

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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()
}
}
}

View file

@ -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());

View file

@ -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());
}
}

View file

@ -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)
}
}

View file

@ -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
*/

View file

@ -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

View file

@ -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)
}

View file

@ -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);
}
}
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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
}
}
}
}

View file

@ -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()

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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);
}
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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) }
)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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()
}
}

View file

@ -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(

View file

@ -51,6 +51,10 @@ class CachedTimelineRemoteMediator(
state: PagingState<Int, TimelineStatusWithAccount>
): MediatorResult {
if (!activeAccount.isLoggedIn()) {
return MediatorResult.Success(endOfPaginationReached = true)
}
try {
var dbEmpty = false

View file

@ -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
}

View file

@ -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?,

View file

@ -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()
}
}

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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");
}
};
}

View file

@ -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)
}

View file

@ -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?
}

View file

@ -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
}

View file

@ -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,

View file

@ -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()
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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,

View file

@ -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(

View file

@ -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;

View file

@ -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
);
}

View 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
/*
* Jacksons 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
}

View file

@ -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()
}
}
}
}

View file

@ -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();
}
}

View file

@ -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()
}
}
}

View file

@ -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>
}

View file

@ -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>
}

View file

@ -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) }
}
}
}
}

View file

@ -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) }
}
}
}

View file

@ -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

View file

@ -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"
}

View file

@ -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
}
}

View file

@ -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

View 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)
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()))
}
)
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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" />

View file

@ -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"

View file

@ -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" />

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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">

View file

@ -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