Merge branch 'develop' into refactor_instancemute

This commit is contained in:
Tak! 2023-09-06 08:55:34 +02:00
commit a96460cb16
252 changed files with 5810 additions and 2827 deletions

View file

@ -772,13 +772,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide
loadedAccount?.let { loadedAccount ->
val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = getDomain(loadedAccount.url)
if (domain.isEmpty()) {
when {
// If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain)
} else {
if (blockingDomain) {
// If the account is from our own domain, muting it is no-op
domain.isEmpty() || viewModel.isFromOwnDomain -> {
menu.removeItem(R.id.action_mute_domain)
}
blockingDomain -> {
muteDomain.title = getString(R.string.action_unmute_domain, domain)
} else {
}
else -> {
muteDomain.title = getString(R.string.action_mute_domain, domain)
}
}

View file

@ -19,6 +19,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.getDomain
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -27,7 +28,7 @@ import javax.inject.Inject
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
accountManager: AccountManager
) : ViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
@ -41,8 +42,13 @@ class AccountViewModel @Inject constructor(
lateinit var accountId: String
var isSelf = false
/** True if the viewed account has the same domain as the active account */
var isFromOwnDomain = false
private var noteUpdateJob: Job? = null
private val activeAccount = accountManager.activeAccount!!
init {
viewModelScope.launch {
eventHub.events.collect { event ->
@ -65,6 +71,8 @@ class AccountViewModel @Inject constructor(
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
isFromOwnDomain = getDomain(account.url) == activeAccount.domain
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
@ -298,7 +306,7 @@ class AccountViewModel @Inject constructor(
fun setAccountInfo(accountId: String) {
this.accountId = accountId
this.isSelf = accountManager.activeAccount?.accountId == accountId
this.isSelf = activeAccount.accountId == accountId
reload(false)
}

View file

@ -82,13 +82,10 @@ class AccountMediaFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
adapter = AccountMediaGridAdapter(
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
useBlurhash = useBlurhash,
context = view.context,
onAttachmentClickListener = ::onAttachmentClick

View file

@ -24,7 +24,6 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
import java.util.Random
class AccountMediaGridAdapter(
private val alwaysShowSensitiveMedia: Boolean,
private val useBlurhash: Boolean,
context: Context,
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
@ -80,7 +79,7 @@ class AccountMediaGridAdapter(
.into(imageView)
imageView.contentDescription = item.attachment.getFormattedDescription(context)
} else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) {
} else if (item.sensitive && !item.isRevealed) {
overlay.show()
overlay.setImageDrawable(mediaHiddenDrawable)

View file

@ -20,6 +20,7 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import retrofit2.HttpException
@ -27,9 +28,9 @@ import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class AccountMediaRemoteMediator(
private val api: MastodonApi,
private val activeAccount: AccountEntity,
private val viewModel: AccountMediaViewModel
) : RemoteMediator<String, AttachmentViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AttachmentViewData>
@ -58,7 +59,7 @@ class AccountMediaRemoteMediator(
}
val attachments = statuses.flatMap { status ->
AttachmentViewData.list(status)
AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia ?: false)
}
if (loadType == LoadType.REFRESH) {

View file

@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import javax.inject.Inject
class AccountMediaViewModel @Inject constructor(
private val accountManager: AccountManager,
api: MastodonApi
) : ViewModel() {
@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor(
var currentSource: AccountMediaPagingSource? = null
val activeAccount = accountManager.activeAccount!!
@OptIn(ExperimentalPagingApi::class)
val media = Pager(
config = PagingConfig(
@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor(
currentSource = source
}
},
remoteMediator = AccountMediaRemoteMediator(api, this)
remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this)
).flow
.cachedIn(viewModelScope)

View file

@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
val id: String? = intent.getStringExtra(EXTRA_ID)
val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
}
supportFragmentManager.commit {
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
}
}
@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
companion object {
private const val EXTRA_TYPE = "type"
private const val EXTRA_ID = "id"
private const val EXTRA_ACCOUNT_LOCKED = "acc_locked"
fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent {
fun newIntent(context: Context, type: Type, id: String? = null): Intent {
return Intent(context, AccountListActivity::class.java).apply {
putExtra(EXTRA_TYPE, type)
putExtra(EXTRA_ID, id)
putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked)
}
}
}

View file

@ -61,7 +61,6 @@ import com.keylesspalace.tusky.view.EndlessOnScrollListener
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class AccountListFragment :
@ -107,13 +106,15 @@ class AccountListFragment :
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
val activeAccount = accountManager.activeAccount!!
adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
Type.FOLLOW_REQUESTS -> {
val headerAdapter = FollowRequestsHeaderAdapter(
instanceName = accountManager.activeAccount!!.domain,
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
instanceName = activeAccount.domain,
accountLocked = activeAccount.locked
)
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
@ -330,7 +331,7 @@ class AccountListFragment :
val linkHeader = response.headers()["Link"]
onFetchAccountsSuccess(accountList, linkHeader)
} catch (exception: IOException) {
} catch (exception: Exception) {
onFetchAccountsFailure(exception)
}
}
@ -404,14 +405,12 @@ class AccountListFragment :
private const val TAG = "AccountList" // logging tag
private const val ARG_TYPE = "type"
private const val ARG_ID = "id"
private const val ARG_ACCOUNT_LOCKED = "acc_locked"
fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment {
fun newInstance(type: Type, id: String? = null): AccountListFragment {
return AccountListFragment().apply {
arguments = Bundle(3).apply {
putSerializable(ARG_TYPE, type)
putString(ARG_ID, id)
putBoolean(ARG_ACCOUNT_LOCKED, accountLocked)
}
}
}

View file

@ -129,7 +129,7 @@ class AnnouncementsActivity :
is Error -> {
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.errorMessageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
refreshAnnouncements()
}
binding.errorMessageView.show()

View file

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
@ -95,6 +94,7 @@ 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.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.MentionSpan
import com.keylesspalace.tusky.util.PickMediaFiles
@ -207,24 +207,9 @@ class ComposeActivity :
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
if (notificationId != -1) {
// ComposeActivity was opened from a notification, delete the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
activeAccount = accountManager.activeAccount ?: return
// If started from an intent then compose as the account ID from the intent.
// Otherwise use the active account. If null then the user is not logged in,
// and return from the activity.
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
activeAccount = if (intentAccountId != -1L) {
accountManager.getAccountById(intentAccountId)
} else {
accountManager.activeAccount
} ?: return
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
val theme = preferences.getString(APP_THEME, APP_THEME_DEFAULT)
if (theme == "black") {
setTheme(R.style.TuskyDialogActivityBlackTheme)
}
@ -280,7 +265,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount))
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
setupComposeField(preferences, viewModel.startingText)
setupContentWarningField(composeOptions?.contentWarning)
setupPollView()
@ -485,7 +470,12 @@ class ComposeActivity :
if (throwable is UploadServerError) {
displayTransientMessage(throwable.errorMessage)
} else {
displayTransientMessage(R.string.error_media_upload_sending)
displayTransientMessage(
getString(
R.string.error_media_upload_sending_fmt,
throwable.message
)
)
}
}
}
@ -943,7 +933,10 @@ class ComposeActivity :
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
for (i in 0 until content.clip.itemCount) {
pickMedia(content.clip.getItemAt(i).uri)
pickMedia(
content.clip.getItemAt(i).uri,
contentInfo.clip.description.label as String?
)
}
}
return split.second
@ -1064,9 +1057,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun pickMedia(uri: Uri) {
private fun pickMedia(uri: Uri, description: String? = null) {
lifecycleScope.launch {
viewModel.pickMedia(uri).onFailure { throwable ->
viewModel.pickMedia(uri, description).onFailure { throwable ->
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
@ -1347,8 +1340,6 @@ class ComposeActivity :
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
private const val VISIBILITY_KEY = "VISIBILITY"
private const val SCHEDULED_TIME_KEY = "SCHEDULE"
@ -1356,26 +1347,15 @@ class ComposeActivity :
/**
* @param options ComposeOptions to configure the ComposeActivity
* @param notificationId the id of the notification that starts the Activity
* @param accountId the id of the account to compose with, null for the current account
* @return an Intent to start the ComposeActivity
*/
@JvmStatic
@JvmOverloads
fun startIntent(
context: Context,
options: ComposeOptions,
notificationId: Int? = null,
accountId: Long? = null
options: ComposeOptions
): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(COMPOSE_OPTIONS_EXTRA, options)
if (notificationId != null) {
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
}
if (accountId != null) {
putExtra(ACCOUNT_ID_EXTRA, accountId)
}
}
}

View file

@ -39,7 +39,6 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -53,7 +52,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(FlowPreview::class)
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
@ -95,7 +93,7 @@ class ComposeViewModel @Inject constructor(
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
lateinit var composeKind: ComposeKind
private lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null

View file

@ -75,10 +75,6 @@ class AddPollOptionsAdapter(
}
private fun validateInput(): Boolean {
if (options.contains("") || options.distinct().size != options.size) {
return false
}
return true
return !(options.contains("") || options.distinct().size != options.size)
}
}

View file

@ -51,7 +51,7 @@ class CaptionDialog : DialogFragment() {
input = binding.imageDescriptionText
val imageView = binding.imageDescriptionView
imageView.maximumScale = 6f
imageView.maxZoom = 6f
input.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,

View file

@ -57,7 +57,9 @@ class ComposeScheduleView
).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private var scheduleDateTime: Calendar? = null
/** The date/time the user has chosen to schedule the status, in UTC */
private var scheduleDateTimeUtc: Calendar? = null
init {
binding.scheduledDateTime.setOnClickListener { openPickDateDialog() }
@ -71,13 +73,13 @@ class ComposeScheduleView
}
private fun updateScheduleUi() {
if (scheduleDateTime == null) {
if (scheduleDateTimeUtc == null) {
binding.scheduledDateTime.text = ""
binding.invalidScheduleWarning.visibility = GONE
return
}
val scheduled = scheduleDateTime!!.time
val scheduled = scheduleDateTimeUtc!!.time
binding.scheduledDateTime.text = String.format(
"%s %s",
dateFormat.format(scheduled),
@ -98,21 +100,37 @@ class ComposeScheduleView
}
fun resetSchedule() {
scheduleDateTime = null
scheduleDateTimeUtc = null
updateScheduleUi()
}
fun openPickDateDialog() {
val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000
// The earliest point in time the calendar should display. Start with current date/time
val earliest = calendar().apply {
// Add the minimum scheduling interval. This may roll the calendar over to the
// next day (e.g. if the current time is 23:57).
add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS)
// Clear out the time components, so it's midnight
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
val calendarConstraints = CalendarConstraints.Builder()
.setValidator(
DateValidatorPointForward.from(yesterday)
)
.setValidator(DateValidatorPointForward.from(earliest.timeInMillis))
.build()
initializeSuggestedTime()
// Work around a misfeature in MaterialDatePicker. The `selection` is treated as
// millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC
// instead of converting to the user's local timezone.
//
// So we have to add the TZ offset before setting it in the picker
val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis)
val picker = MaterialDatePicker.Builder
.datePicker()
.setSelection(scheduleDateTime!!.timeInMillis)
.setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset)
.setCalendarConstraints(calendarConstraints)
.build()
picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) }
@ -129,11 +147,12 @@ class ComposeScheduleView
private fun openPickTimeDialog() {
val pickerBuilder = MaterialTimePicker.Builder()
scheduleDateTime?.let {
scheduleDateTimeUtc?.let {
pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY])
.setMinute(it[Calendar.MINUTE])
}
pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis))
pickerBuilder.setTimeFormat(getTimeFormat(context))
val picker = pickerBuilder.build()
@ -154,7 +173,7 @@ class ComposeScheduleView
fun setDateTime(scheduledAt: String?) {
val date = getDateTime(scheduledAt) ?: return
initializeSuggestedTime()
scheduleDateTime!!.time = date
scheduleDateTimeUtc!!.time = date
updateScheduleUi()
}
@ -180,24 +199,24 @@ class ComposeScheduleView
// see https://github.com/material-components/material-components-android/issues/882
newDate.timeZone = TimeZone.getTimeZone("UTC")
newDate.timeInMillis = selection
scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE]
openPickTimeDialog()
}
private fun onTimeSet(hourOfDay: Int, minute: Int) {
initializeSuggestedTime()
scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay)
scheduleDateTime?.set(Calendar.MINUTE, minute)
scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay)
scheduleDateTimeUtc?.set(Calendar.MINUTE, minute)
updateScheduleUi()
listener?.onTimeSet(time)
}
val time: String?
get() = scheduleDateTime?.time?.let { iso8601.format(it) }
get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) }
private fun initializeSuggestedTime() {
if (scheduleDateTime == null) {
scheduleDateTime = calendar().apply {
if (scheduleDateTimeUtc == null) {
scheduleDateTimeUtc = calendar().apply {
add(Calendar.MINUTE, 15)
}
}

View file

@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
ImageView avatarView = avatars[i];
if (i < accounts.size()) {
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
avatarRadius48dp, statusDisplayOptions.animateAvatars());
avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
avatarView.setVisibility(View.VISIBLE);
} else {
avatarView.setVisibility(View.GONE);

View file

@ -134,6 +134,7 @@ class ConversationsFragment :
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
binding.statusView.showHelp(R.string.help_empty_conversations)
}
}
is LoadState.Error -> {

View file

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.components.filters
import android.content.Context
import android.content.DialogInterface.BUTTON_POSITIVE
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
@ -81,7 +82,11 @@ class EditFilterActivity : BaseActivity() {
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
binding.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
binding.filterDeleteButton.setOnClickListener {
lifecycleScope.launch {
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter()
}
}
binding.filterDeleteButton.visible(originalFilter != null)
for (switch in contextSwitches.keys) {

View file

@ -0,0 +1,29 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.components.filters
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.await
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_delete_filter_text, filterTitle))
.setCancelable(true)
.create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)

View file

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.components.filters
import android.content.DialogInterface.BUTTON_POSITIVE
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
@ -60,18 +61,19 @@ class FiltersActivity : BaseActivity(), FiltersListener {
when (state.loadingState) {
FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide()
FiltersViewModel.LoadingState.ERROR_NETWORK -> {
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) {
loadFilters()
}
binding.messageView.show()
}
FiltersViewModel.LoadingState.ERROR_OTHER -> {
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) {
loadFilters()
}
binding.messageView.show()
}
FiltersViewModel.LoadingState.LOADED -> {
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
if (state.filters.isEmpty()) {
binding.messageView.setup(
R.drawable.elephant_friend_empty,
@ -81,7 +83,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
binding.messageView.show()
} else {
binding.messageView.hide()
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
}
}
}
@ -104,7 +105,11 @@ class FiltersActivity : BaseActivity(), FiltersListener {
}
override fun deleteFilter(filter: Filter) {
viewModel.deleteFilter(filter, binding.root)
lifecycleScope.launch {
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) {
viewModel.deleteFilter(filter, binding.root)
}
}
}
override fun updateFilter(updatedFilter: Filter) {

View file

@ -28,5 +28,6 @@ data class InstanceInfo(
val maxMediaAttachments: Int,
val maxFields: Int,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
val maxFieldValueLength: Int?,
val version: String?
)

View file

@ -99,7 +99,8 @@ class InstanceInfoRepository @Inject constructor(
maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
maxFieldNameLength = instanceInfo?.maxFieldNameLength,
maxFieldValueLength = instanceInfo?.maxFieldValueLength
maxFieldValueLength = instanceInfo?.maxFieldValueLength,
version = instanceInfo?.version
)
}
}

View file

@ -99,7 +99,7 @@ sealed class LoginResult : Parcelable {
data class Err(val errorMessage: String) : LoginResult()
@Parcelize
object Cancel : LoginResult()
data object Cancel : LoginResult()
}
/** Activity to do Oauth process using WebView. */

View file

@ -85,13 +85,6 @@ public class NotificationHelper {
/** Dynamic notification IDs start here */
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
/**
* constants used in Intents
*/
public static final String ACCOUNT_ID = "account_id";
public static final String TYPE = APPLICATION_ID + ".notification.type";
private static final String TAG = "NotificationHelper";
public static final String REPLY_ACTION = "REPLY_ACTION";
@ -245,7 +238,7 @@ public class NotificationHelper {
Bundle extras = new Bundle();
// Add the sending account's name, so it can be used when summarising this notification
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString());
extras.putSerializable(EXTRA_NOTIFICATION_TYPE, body.getType());
builder.addExtras(extras);
// Only alert for the first notification of a batch to avoid multiple alerts at once
@ -285,7 +278,7 @@ public class NotificationHelper {
int accountId = (int) account.getId();
// Initialise the map with all channel IDs.
for (Notification.Type ty : Notification.Type.values()) {
for (Notification.Type ty : Notification.Type.getEntries()) {
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
}
@ -325,11 +318,10 @@ public class NotificationHelper {
// Create a notification that summarises the other notifications in this group
// All notifications in this group have the same type, so get it from the first.
String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE);
Notification.Type notificationType = (Notification.Type)members.get(0).getNotification().extras.getSerializable(EXTRA_NOTIFICATION_TYPE);
Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType);
Intent summaryResultIntent = new Intent(context, MainActivity.class);
summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId);
summaryResultIntent.putExtra(TYPE, notificationType);
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
summaryStackBuilder.addNextIntent(summaryResultIntent);
@ -373,10 +365,8 @@ public class NotificationHelper {
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
// we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class);
eventResultIntent.putExtra(ACCOUNT_ID, account.getId());
eventResultIntent.putExtra(TYPE, body.getType().name());
Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType());
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
eventStackBuilder.addParentStack(MainActivity.class);
eventStackBuilder.addNextIntent(eventResultIntent);
@ -464,12 +454,7 @@ public class NotificationHelper {
composeOptions.setLanguage(actionableStatus.getLanguage());
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
Intent composeIntent = ComposeActivity.startIntent(
context,
composeOptions,
notificationId,
account.getId()
);
Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId());
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

View file

@ -28,7 +28,6 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
@ -46,7 +45,6 @@ import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
@ -123,21 +121,6 @@ class NotificationsFragment :
return inflater.inflate(R.layout.fragment_timeline_notifications, container, false)
}
private fun updateFilterVisibility(showFilter: Boolean) {
val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams
if (showFilter) {
binding.appBarOptions.setExpanded(true, false)
binding.appBarOptions.visibility = View.VISIBLE
// Set content behaviour to hide filter on scroll
params.behavior = ScrollingViewBehavior()
} else {
binding.appBarOptions.setExpanded(false, false)
binding.appBarOptions.visibility = View.GONE
// Clear behaviour to hide app bar
params.behavior = null
}
}
private fun confirmClearNotifications() {
AlertDialog.Builder(requireContext())
.setMessage(R.string.notification_clear_text)
@ -215,8 +198,6 @@ class NotificationsFragment :
footer = NotificationsLoadStateAdapter { adapter.retry() }
)
binding.buttonClear.setOnClickListener { confirmClearNotifications() }
binding.buttonFilter.setOnClickListener { showFilterDialog() }
(binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations =
false
@ -293,7 +274,7 @@ class NotificationsFragment :
val position = adapter.snapshot().indexOfFirst {
it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id
}
if (position != RecyclerView.NO_POSITION) {
if (position != NO_POSITION) {
adapter.notifyItemChanged(position)
}
}
@ -369,10 +350,10 @@ class NotificationsFragment :
}
}
// Update filter option visibility from uiState
launch {
viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) }
}
// Collect the uiState. Nothing is done with it, but if you don't collect it then
// accessing viewModel.uiState.value (e.g., when the filter dialog is created)
// returns an empty object.
launch { viewModel.uiState.collect() }
// Update status display from statusDisplayOptions. If the new options request
// relative time display collect the flow to periodically update the timestamp in the list gui elements.
@ -418,13 +399,13 @@ class NotificationsFragment :
when ((loadState.refresh as LoadState.Error).error) {
is IOException -> {
binding.statusView.setup(
R.drawable.elephant_offline,
R.drawable.errorphant_offline,
R.string.error_network
) { adapter.retry() }
}
else -> {
binding.statusView.setup(
R.drawable.elephant_error,
R.drawable.errorphant_error,
R.string.error_generic
) { adapter.retry() }
}
@ -439,10 +420,17 @@ class NotificationsFragment :
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.fragment_notifications, menu)
val iconColor = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
menu.findItem(R.id.action_refresh)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply {
sizeDp = 20
colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary)
colorInt = iconColor
}
}
menu.findItem(R.id.action_edit_notification_filter)?.apply {
icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_tune).apply {
sizeDp = 20
colorInt = iconColor
}
}
}
@ -458,6 +446,14 @@ class NotificationsFragment :
viewModel.accept(InfallibleUiAction.LoadNewest)
true
}
R.id.action_edit_notification_filter -> {
showFilterDialog()
true
}
R.id.action_clear_notifications -> {
confirmClearNotifications()
true
}
else -> false
}
}
@ -518,7 +514,11 @@ class NotificationsFragment :
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.peek(position)?.statusViewData?.status ?: return
super.viewMedia(attachmentIndex, list(status), view)
super.viewMedia(
attachmentIndex,
list(status, viewModel.statusDisplayOptions.value.showSensitiveMedia),
view
)
}
override fun onViewThread(position: Int) {
@ -621,7 +621,6 @@ class NotificationsFragment :
override fun onReselect() {
if (isAdded) {
binding.appBarOptions.setExpanded(true, false)
layoutManager.scrollToPosition(0)
}
}

View file

@ -124,7 +124,7 @@ class NotificationsPagingAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (NotificationViewKind.values()[viewType]) {
return when (NotificationViewKind.entries[viewType]) {
NotificationViewKind.STATUS -> {
StatusViewHolder(
ItemStatusBinding.inflate(inflater, parent, false),

View file

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -70,29 +69,23 @@ import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
data class UiState(
/** Filtered notification types */
val activeFilter: Set<Notification.Type> = emptySet(),
/** True if the UI to filter and clear notifications should be shown */
val showFilterOptions: Boolean = false,
/** True if the FAB should be shown while scrolling */
val showFabWhileScrolling: Boolean = true
)
/** Preferences the UI reacts to */
data class UiPrefs(
val showFabWhileScrolling: Boolean,
val showFilter: Boolean
val showFabWhileScrolling: Boolean
) {
companion object {
/** Relevant preference keys. Changes to any of these trigger a display update */
val prefKeys = setOf(
PrefKeys.FAB_HIDE,
PrefKeys.SHOW_NOTIFICATIONS_FILTER
PrefKeys.FAB_HIDE
)
}
}
@ -103,7 +96,7 @@ sealed class UiAction
/** Actions the user can trigger from the UI. These actions may fail. */
sealed class FallibleUiAction : UiAction() {
/** Clear all notifications */
object ClearNotifications : FallibleUiAction()
data object ClearNotifications : FallibleUiAction()
}
/**
@ -129,7 +122,7 @@ sealed class InfallibleUiAction : UiAction() {
// Resets the account's `lastNotificationId`, which can't fail, which is why this is
// infallible. Reloading the data may fail, but that's handled by the paging system /
// adapter refresh logic.
object LoadNewest : InfallibleUiAction()
data object LoadNewest : InfallibleUiAction()
}
/** Actions the user can trigger on an individual notification. These may fail. */
@ -146,13 +139,13 @@ sealed class UiSuccess {
// of these three should trigger the UI to refresh.
/** A user was blocked */
object Block : UiSuccess()
data object Block : UiSuccess()
/** A user was muted */
object Mute : UiSuccess()
data object Mute : UiSuccess()
/** A conversation was muted */
object MuteConversation : UiSuccess()
data object MuteConversation : UiSuccess()
}
/** The result of a successful action on a notification */
@ -286,7 +279,7 @@ sealed class UiError(
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
private val preferences: SharedPreferences,
@ -497,7 +490,6 @@ class NotificationsViewModel @Inject constructor(
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
UiState(
activeFilter = filter.filter,
showFilterOptions = prefs.showFilter,
showFabWhileScrolling = prefs.showFabWhileScrolling
)
}.stateIn(
@ -546,8 +538,7 @@ class NotificationsViewModel @Inject constructor(
.onStart { emit(toPrefs()) }
private fun toPrefs() = UiPrefs(
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false),
showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true)
showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false)
)
companion object {

View file

@ -34,6 +34,7 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.setAppNightMode
@ -145,8 +146,8 @@ class PreferencesActivity :
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"appTheme" -> {
val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT)
APP_THEME -> {
val theme = sharedPreferences.getNonNullString(APP_THEME, APP_THEME_DEFAULT)
Log.d("activeTheme", theme)
setAppNightMode(theme)

View file

@ -208,13 +208,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.SHOW_NOTIFICATIONS_FILTER
setTitle(R.string.pref_title_show_notifications_filter)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.CONFIRM_REBLOGS

View file

@ -19,9 +19,9 @@ import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.checkBoxPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference
class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@ -29,14 +29,14 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
preferenceCategory(R.string.title_home) { category ->
category.isIconSpaceReserved = false
checkBoxPreference {
switchPreference {
setTitle(R.string.pref_title_show_boosts)
key = PrefKeys.TAB_FILTER_HOME_BOOSTS
setDefaultValue(true)
isIconSpaceReserved = false
}
checkBoxPreference {
switchPreference {
setTitle(R.string.pref_title_show_replies)
key = PrefKeys.TAB_FILTER_HOME_REPLIES
setDefaultValue(true)

View file

@ -40,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, SearchView.OnQueryTextListener {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -91,8 +91,6 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
searchViewMenuItem.expandActionView()
val searchView = searchViewMenuItem.actionView as SearchView
setupSearchView(searchView)
searchView.setQuery(viewModel.currentQuery, false)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
@ -150,9 +148,23 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider {
val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt()
searchView.maxWidth = pxScreenWidth - pxBuffer
// Keep text that was entered also when switching to a different tab (before the search is executed)
searchView.setOnQueryTextListener(this)
searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false)
searchView.requestFocus()
}
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.currentSearchFieldContent = newText
return false
}
override fun androidInjector() = androidInjector
companion object {

View file

@ -45,6 +45,7 @@ class SearchViewModel @Inject constructor(
) : ViewModel() {
var currentQuery: String = ""
var currentSearchFieldContent: String? = null
val activeAccount: AccountEntity?
get() = accountManager.activeAccount

View file

@ -1,9 +1,10 @@
package com.keylesspalace.tusky.components.timeline.util
import com.google.gson.JsonParseException
import retrofit2.HttpException
import java.io.IOException
fun Throwable.isExpected() = this is IOException || this is HttpException
fun Throwable.isExpected() = this is IOException || this is HttpException || this is JsonParseException
inline fun <T> ifExpected(
t: Throwable,

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.timeline.viewmodel
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
@ -117,6 +118,7 @@ class CachedTimelineRemoteMediator(
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return ifExpected(e) {
Log.w(TAG, "Failed to load timeline", e)
MediatorResult.Error(e)
}
}
@ -175,4 +177,8 @@ class CachedTimelineRemoteMediator(
}
return overlappedStatuses
}
companion object {
private const val TAG = "CachedTimelineRM"
}
}

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.timeline.viewmodel
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
@ -106,8 +107,13 @@ class NetworkTimelineRemoteMediator(
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return ifExpected(e) {
Log.w(TAG, "Failed to load timeline", e)
MediatorResult.Error(e)
}
}
}
companion object {
private const val TAG = "NetworkTimelineRM"
}
}

View file

@ -48,7 +48,7 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector {
if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
supportFragmentManager.commit {
val fragment = TrendingFragment.newInstance()
val fragment = TrendingTagsFragment.newInstance()
replace(R.id.fragmentContainer, fragment)
}
}

View file

@ -24,7 +24,7 @@ import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingAdapter(
class TrendingTagsAdapter(
private val onViewTag: (String) -> Unit
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {

View file

@ -33,8 +33,8 @@ import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingTagsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
@ -48,8 +48,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class TrendingFragment :
Fragment(R.layout.fragment_trending),
class TrendingTagsFragment :
Fragment(R.layout.fragment_trending_tags),
OnRefreshListener,
Injectable,
ReselectableFragment,
@ -58,11 +58,11 @@ class TrendingFragment :
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: TrendingViewModel by viewModels { viewModelFactory }
private val viewModel: TrendingTagsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTrendingBinding::bind)
private val binding by viewBinding(FragmentTrendingTagsBinding::bind)
private val adapter = TrendingAdapter(::onViewTag)
private val adapter = TrendingTagsAdapter(::onViewTag)
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@ -111,8 +111,8 @@ class TrendingFragment :
spanSizeLookup = object : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (adapter.getItemViewType(position)) {
TrendingAdapter.VIEW_TYPE_HEADER -> columnCount
TrendingAdapter.VIEW_TYPE_TAG -> 1
TrendingTagsAdapter.VIEW_TYPE_HEADER -> columnCount
TrendingTagsAdapter.VIEW_TYPE_TAG -> 1
else -> -1
}
}
@ -139,15 +139,15 @@ class TrendingFragment :
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
private fun processViewState(uiState: TrendingTagsViewModel.TrendingTagsUiState) {
Log.d(TAG, uiState.loadingState.name)
when (uiState.loadingState) {
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState()
TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError()
}
}
@ -194,7 +194,7 @@ class TrendingFragment :
binding.swipeRefreshLayout.isRefreshing = false
binding.messageView.setup(
R.drawable.elephant_offline,
R.drawable.errorphant_offline,
R.string.error_network
) { refreshContent() }
}
@ -206,7 +206,7 @@ class TrendingFragment :
binding.swipeRefreshLayout.isRefreshing = false
binding.messageView.setup(
R.drawable.elephant_error,
R.drawable.errorphant_error,
R.string.error_generic
) { refreshContent() }
}
@ -247,8 +247,8 @@ class TrendingFragment :
}
companion object {
private const val TAG = "TrendingFragment"
private const val TAG = "TrendingTagsFragment"
fun newInstance() = TrendingFragment()
fun newInstance() = TrendingTagsFragment()
}
}

View file

@ -35,7 +35,7 @@ import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class TrendingViewModel @Inject constructor(
class TrendingTagsViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : ViewModel() {
@ -43,13 +43,13 @@ class TrendingViewModel @Inject constructor(
INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER
}
data class TrendingUiState(
data class TrendingTagsUiState(
val trendingViewData: List<TrendingViewData>,
val loadingState: LoadingState
)
val uiState: Flow<TrendingUiState> get() = _uiState
private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL))
val uiState: Flow<TrendingTagsUiState> get() = _uiState
private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL))
init {
invalidate()
@ -73,38 +73,42 @@ class TrendingViewModel @Inject constructor(
*/
fun invalidate(refresh: Boolean = false) = viewModelScope.launch {
if (refresh) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.REFRESHING)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING)
}
val deferredFilters = async { mastodonApi.getFilters() }
mastodonApi.trendingTags().fold(
{ tagResponse ->
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
}
val tags = tagResponse
.filter { tag ->
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
} ?: false
val firstTag = tagResponse.firstOrNull()
_uiState.value = if (firstTag == null) {
TrendingTagsUiState(emptyList(), LoadingState.LOADED)
} else {
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
}
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData()
val tags = tagResponse
.filter { tag ->
homeFilters?.none { filter ->
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
} ?: false
}
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData()
val firstTag = tagResponse.first()
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
_uiState.value = TrendingUiState(listOf(header) + tags, LoadingState.LOADED)
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED)
}
},
{ error ->
Log.w(TAG, "failed loading trending tags", error)
if (error is IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_NETWORK)
} else {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_OTHER)
}
}
)

View file

@ -24,36 +24,34 @@ import androidx.core.view.forEach
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
class ConversationLineItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
private val divider: Drawable = ContextCompat.getDrawable(context, R.drawable.conversation_thread_line)!!
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start)
val dividerEnd = dividerStart + divider.intrinsicWidth
private val avatarTopMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin)
private val halfAvatarHeight = context.resources.getDimensionPixelSize(R.dimen.timeline_status_avatar_height) / 2
private val statusLineMarginStart = context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start)
val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin)
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val dividerStart = parent.paddingStart + statusLineMarginStart
val dividerEnd = dividerStart + divider.intrinsicWidth
val items = (parent.adapter as ThreadAdapter).currentList
parent.forEach { child ->
parent.forEach { statusItemView ->
val position = parent.getChildAdapterPosition(statusItemView)
val position = parent.getChildAdapterPosition(child)
val current = items.getOrNull(position)
if (current != null) {
items.getOrNull(position)?.let { current ->
val above = items.getOrNull(position - 1)
val dividerTop = if (above != null && above.id == current.status.inReplyToId) {
child.top
statusItemView.top
} else {
child.top + avatarMargin
statusItemView.top + avatarTopMargin + halfAvatarHeight
}
val below = items.getOrNull(position + 1)
val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) {
child.bottom
statusItemView.bottom
} else {
child.top + avatarMargin
statusItemView.top + avatarTopMargin + halfAvatarHeight
}
if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) {

View file

@ -336,7 +336,11 @@ class ViewThreadFragment :
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.currentList[position].status
super.viewMedia(attachmentIndex, list(status), view)
super.viewMedia(
attachmentIndex,
list(status, alwaysShowSensitiveMedia),
view
)
}
override fun onViewThread(position: Int) {

View file

@ -516,7 +516,7 @@ class ViewThreadViewModel @Inject constructor(
sealed interface ThreadUiState {
/** The initial load of the detailed status for this thread */
object Loading : ThreadUiState
data object Loading : ThreadUiState
/** Loading the detailed status has completed, now loading ancestors/descendants */
data class LoadingThread(
@ -535,7 +535,7 @@ sealed interface ThreadUiState {
) : ThreadUiState
/** Refreshing the thread with a swipe */
object Refreshing : ThreadUiState
data object Refreshing : ThreadUiState
}
enum class RevealButtonState {

View file

@ -51,10 +51,10 @@ class ViewEditsAdapter(
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
/** Size of large text in this theme, in px */
var largeTextSizePx: Float = 0f
private var largeTextSizePx: Float = 0f
/** Size of medium text in this theme, in px */
var mediumTextSizePx: Float = 0f
private var mediumTextSizePx: Float = 0f
override fun onCreateViewHolder(
parent: ViewGroup,

View file

@ -132,12 +132,12 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
}
sealed interface EditsUiState {
object Initial : EditsUiState
object Loading : EditsUiState
data object Initial : EditsUiState
data object Loading : EditsUiState
// "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success,
// and state flows don't emit repeated states, so the UI never updates.
object Refreshing : EditsUiState
data object Refreshing : EditsUiState
class Error(val throwable: Throwable) : EditsUiState
data class Success(
val edits: List<StatusEdit>