mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
This was so much work wow. I think it works pretty well and is the best compromise between all the alternative we considered. Yes the pull-to-refreh on the notifications works slightly different now when the new bar is visible, but I don't think there is a way around that. Things I plan to do later, i.e. not as part of this PR or release: - Cache the notification policy summary for better offline behavior and less view shifting when it loads - try to reduce some of the code duplications that are now in there - if there is user demand, add a "legacy mode" setting where this feature is disabled even if the server would support it closes #4331 closes #4550 as won't do closes #4712 as won't do <img src="https://github.com/user-attachments/assets/de322d3c-3775-41e7-be57-28ab7fbaecdf" width="240"/> <img src="https://github.com/user-attachments/assets/1ce958a4-4f15-484c-a337-5ad93f36046c" width="240"/> <img src="https://github.com/user-attachments/assets/98b0482b-1c05-4c99-a371-f7f4d8a69abd" width="240"/>pull/4784/head
42 changed files with 2401 additions and 97 deletions
@ -0,0 +1,73 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications |
||||
|
||||
import android.view.LayoutInflater |
||||
import android.view.ViewGroup |
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.databinding.ItemFilteredNotificationsInfoBinding |
||||
import com.keylesspalace.tusky.usecase.NotificationPolicyState |
||||
import com.keylesspalace.tusky.util.BindingHolder |
||||
import java.text.NumberFormat |
||||
|
||||
class NotificationPolicySummaryAdapter( |
||||
private val onOpenDetails: () -> Unit |
||||
) : RecyclerView.Adapter<BindingHolder<ItemFilteredNotificationsInfoBinding>>() { |
||||
|
||||
private var state: NotificationPolicyState = NotificationPolicyState.Loading |
||||
|
||||
fun updateState(newState: NotificationPolicyState) { |
||||
val oldShowInfo = state.shouldShowInfo() |
||||
val newShowInfo = newState.shouldShowInfo() |
||||
state = newState |
||||
if (oldShowInfo && !newShowInfo) { |
||||
notifyItemRemoved(0) |
||||
} else if (!oldShowInfo && newShowInfo) { |
||||
notifyItemInserted(0) |
||||
} else if (oldShowInfo && newShowInfo) { |
||||
notifyItemChanged(0) |
||||
} |
||||
} |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFilteredNotificationsInfoBinding> { |
||||
val binding = ItemFilteredNotificationsInfoBinding.inflate( |
||||
LayoutInflater.from(parent.context), |
||||
parent, |
||||
false |
||||
) |
||||
binding.root.setOnClickListener { |
||||
onOpenDetails() |
||||
} |
||||
return BindingHolder(binding) |
||||
} |
||||
|
||||
override fun getItemCount() = if (state.shouldShowInfo()) 1 else 0 |
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemFilteredNotificationsInfoBinding>, position: Int) { |
||||
val policySummary = (state as? NotificationPolicyState.Loaded)?.policy?.summary |
||||
if (policySummary != null) { |
||||
val binding = holder.binding |
||||
val context = holder.binding.root.context |
||||
binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policySummary.pendingRequestsCount) |
||||
binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policySummary.pendingNotificationsCount) |
||||
} |
||||
} |
||||
|
||||
private fun NotificationPolicyState.shouldShowInfo(): Boolean { |
||||
return this is NotificationPolicyState.Loaded && this.policy.summary.pendingNotificationsCount > 0 |
||||
} |
||||
} |
||||
@ -0,0 +1,204 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.os.Bundle |
||||
import android.util.Log |
||||
import android.view.Menu |
||||
import android.view.MenuInflater |
||||
import android.view.MenuItem |
||||
import androidx.activity.result.contract.ActivityResultContract |
||||
import androidx.activity.viewModels |
||||
import androidx.core.view.MenuProvider |
||||
import androidx.lifecycle.lifecycleScope |
||||
import androidx.paging.LoadState |
||||
import androidx.recyclerview.widget.DividerItemDecoration |
||||
import androidx.recyclerview.widget.LinearLayoutManager |
||||
import androidx.recyclerview.widget.SimpleItemAnimator |
||||
import com.google.android.material.color.MaterialColors |
||||
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG |
||||
import com.google.android.material.snackbar.Snackbar |
||||
import com.keylesspalace.tusky.BaseActivity |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.components.notifications.requests.details.NotificationRequestDetailsActivity |
||||
import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity |
||||
import com.keylesspalace.tusky.databinding.ActivityNotificationRequestsBinding |
||||
import com.keylesspalace.tusky.entity.Emoji |
||||
import com.keylesspalace.tusky.entity.NotificationRequest |
||||
import com.keylesspalace.tusky.settings.PrefKeys |
||||
import com.keylesspalace.tusky.util.getErrorString |
||||
import com.keylesspalace.tusky.util.hide |
||||
import com.keylesspalace.tusky.util.show |
||||
import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation |
||||
import com.keylesspalace.tusky.util.viewBinding |
||||
import com.keylesspalace.tusky.util.visible |
||||
import com.mikepenz.iconics.IconicsDrawable |
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial |
||||
import com.mikepenz.iconics.utils.colorInt |
||||
import com.mikepenz.iconics.utils.sizeDp |
||||
import dagger.hilt.android.AndroidEntryPoint |
||||
import kotlin.String |
||||
import kotlin.getValue |
||||
import kotlinx.coroutines.flow.collectLatest |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@AndroidEntryPoint |
||||
class NotificationRequestsActivity : BaseActivity(), MenuProvider { |
||||
|
||||
private val viewModel: NotificationRequestsViewModel by viewModels() |
||||
|
||||
private val binding by viewBinding(ActivityNotificationRequestsBinding::inflate) |
||||
|
||||
private val notificationRequestDetails = registerForActivityResult(NotificationRequestDetailsResultContract()) { id -> |
||||
if (id != null) { |
||||
viewModel.removeNotificationRequest(id) |
||||
} |
||||
} |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
setContentView(binding.root) |
||||
|
||||
addMenuProvider(this) |
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar) |
||||
supportActionBar?.run { |
||||
setTitle(R.string.filtered_notifications_title) |
||||
setDisplayHomeAsUpEnabled(true) |
||||
setDisplayShowHomeEnabled(true) |
||||
} |
||||
|
||||
setupAdapter().let { adapter -> |
||||
setupRecyclerView(adapter) |
||||
|
||||
lifecycleScope.launch { |
||||
viewModel.pager.collectLatest { pagingData -> |
||||
adapter.submitData(pagingData) |
||||
} |
||||
} |
||||
} |
||||
|
||||
lifecycleScope.launch { |
||||
viewModel.error.collect { error -> |
||||
Snackbar.make( |
||||
binding.root, |
||||
error.getErrorString(this@NotificationRequestsActivity), |
||||
LENGTH_LONG |
||||
).show() |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun setupRecyclerView(adapter: NotificationRequestsAdapter) { |
||||
binding.notificationRequestsView.adapter = adapter |
||||
binding.notificationRequestsView.setHasFixedSize(true) |
||||
binding.notificationRequestsView.layoutManager = LinearLayoutManager(this) |
||||
binding.notificationRequestsView.addItemDecoration( |
||||
DividerItemDecoration(this, DividerItemDecoration.VERTICAL) |
||||
) |
||||
(binding.notificationRequestsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false |
||||
} |
||||
|
||||
private fun setupAdapter(): NotificationRequestsAdapter { |
||||
return NotificationRequestsAdapter( |
||||
onAcceptRequest = viewModel::acceptNotificationRequest, |
||||
onDismissRequest = viewModel::dismissNotificationRequest, |
||||
onOpenDetails = ::onOpenRequestDetails, |
||||
animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), |
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) |
||||
).apply { |
||||
addLoadStateListener { loadState -> |
||||
binding.notificationRequestsProgressBar.visible( |
||||
loadState.refresh == LoadState.Loading && itemCount == 0 |
||||
) |
||||
|
||||
if (loadState.refresh is LoadState.Error) { |
||||
binding.notificationRequestsView.hide() |
||||
binding.notificationRequestsMessageView.show() |
||||
val errorState = loadState.refresh as LoadState.Error |
||||
binding.notificationRequestsMessageView.setup(errorState.error) { retry() } |
||||
Log.w(TAG, "error loading notification requests", errorState.error) |
||||
} else { |
||||
binding.notificationRequestsView.show() |
||||
binding.notificationRequestsMessageView.hide() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { |
||||
menuInflater.inflate(R.menu.activity_notification_requests, menu) |
||||
menu.findItem(R.id.open_settings)?.apply { |
||||
icon = IconicsDrawable(this@NotificationRequestsActivity, GoogleMaterial.Icon.gmd_settings).apply { |
||||
sizeDp = 20 |
||||
colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean { |
||||
return when (menuItem.itemId) { |
||||
R.id.open_settings -> { |
||||
val intent = NotificationPoliciesActivity.newIntent(this) |
||||
startActivityWithSlideInAnimation(intent) |
||||
true |
||||
} |
||||
else -> false |
||||
} |
||||
} |
||||
|
||||
private fun onOpenRequestDetails(reqeuest: NotificationRequest) { |
||||
notificationRequestDetails.launch( |
||||
NotificationRequestDetailsResultContractInput( |
||||
notificationRequestId = reqeuest.id, |
||||
accountId = reqeuest.account.id, |
||||
accountName = reqeuest.account.name, |
||||
accountEmojis = reqeuest.account.emojis |
||||
) |
||||
) |
||||
} |
||||
|
||||
class NotificationRequestDetailsResultContractInput( |
||||
val notificationRequestId: String, |
||||
val accountId: String, |
||||
val accountName: String, |
||||
val accountEmojis: List<Emoji> |
||||
) |
||||
|
||||
class NotificationRequestDetailsResultContract : ActivityResultContract<NotificationRequestDetailsResultContractInput, String?>() { |
||||
override fun createIntent(context: Context, input: NotificationRequestDetailsResultContractInput): Intent { |
||||
return NotificationRequestDetailsActivity.newIntent( |
||||
notificationRequestId = input.notificationRequestId, |
||||
accountId = input.accountId, |
||||
accountName = input.accountName, |
||||
accountEmojis = input.accountEmojis, |
||||
context = context |
||||
) |
||||
} |
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): String? { |
||||
return intent?.getStringExtra(NotificationRequestDetailsActivity.EXTRA_NOTIFICATION_REQUEST_ID) |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "NotificationRequestsActivity" |
||||
fun newIntent(context: Context) = Intent(context, NotificationRequestsActivity::class.java) |
||||
} |
||||
} |
||||
@ -0,0 +1,92 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests |
||||
|
||||
import android.view.LayoutInflater |
||||
import android.view.ViewGroup |
||||
import androidx.annotation.OptIn |
||||
import androidx.paging.PagingDataAdapter |
||||
import androidx.recyclerview.widget.DiffUtil |
||||
import com.google.android.material.badge.ExperimentalBadgeUtils |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.databinding.ItemNotificationRequestBinding |
||||
import com.keylesspalace.tusky.entity.NotificationRequest |
||||
import com.keylesspalace.tusky.util.BindingHolder |
||||
import com.keylesspalace.tusky.util.emojify |
||||
import com.keylesspalace.tusky.util.loadAvatar |
||||
|
||||
class NotificationRequestsAdapter( |
||||
private val onAcceptRequest: (notificationRequestId: String) -> Unit, |
||||
private val onDismissRequest: (notificationRequestId: String) -> Unit, |
||||
private val onOpenDetails: (notificationRequest: NotificationRequest) -> Unit, |
||||
private val animateAvatar: Boolean, |
||||
private val animateEmojis: Boolean, |
||||
) : PagingDataAdapter<NotificationRequest, BindingHolder<ItemNotificationRequestBinding>>(NOTIFICATION_REQUEST_COMPARATOR) { |
||||
|
||||
override fun onCreateViewHolder( |
||||
parent: ViewGroup, |
||||
viewType: Int |
||||
): BindingHolder<ItemNotificationRequestBinding> { |
||||
val binding = ItemNotificationRequestBinding.inflate( |
||||
LayoutInflater.from(parent.context), |
||||
parent, |
||||
false |
||||
) |
||||
return BindingHolder(binding) |
||||
} |
||||
|
||||
@OptIn(ExperimentalBadgeUtils::class) |
||||
override fun onBindViewHolder(holder: BindingHolder<ItemNotificationRequestBinding>, position: Int) { |
||||
getItem(position)?.let { notificationRequest -> |
||||
val binding = holder.binding |
||||
val context = binding.root.context |
||||
val account = notificationRequest.account |
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) |
||||
loadAvatar(account.avatar, binding.notificationRequestAvatar, avatarRadius, animateAvatar) |
||||
|
||||
binding.notificationRequestBadge.text = notificationRequest.notificationsCount |
||||
|
||||
val emojifiedName = account.name.emojify( |
||||
account.emojis, |
||||
binding.notificationRequestDisplayName, |
||||
animateEmojis |
||||
) |
||||
binding.notificationRequestDisplayName.text = emojifiedName |
||||
val formattedUsername = context.getString(R.string.post_username_format, account.username) |
||||
binding.notificationRequestUsername.text = formattedUsername |
||||
|
||||
binding.notificationRequestAccept.setOnClickListener { |
||||
onAcceptRequest(notificationRequest.id) |
||||
} |
||||
binding.notificationRequestDismiss.setOnClickListener { |
||||
onDismissRequest(notificationRequest.id) |
||||
} |
||||
binding.root.setOnClickListener { |
||||
onOpenDetails(notificationRequest) |
||||
} |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
val NOTIFICATION_REQUEST_COMPARATOR = object : DiffUtil.ItemCallback<NotificationRequest>() { |
||||
override fun areItemsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean = |
||||
oldItem.id == newItem.id |
||||
override fun areContentsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean = |
||||
oldItem == newItem |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,35 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests |
||||
|
||||
import androidx.paging.PagingSource |
||||
import androidx.paging.PagingState |
||||
import com.keylesspalace.tusky.entity.NotificationRequest |
||||
|
||||
class NotificationRequestsPagingSource( |
||||
private val requests: List<NotificationRequest>, |
||||
private val nextKey: String? |
||||
) : PagingSource<String, NotificationRequest>() { |
||||
override fun getRefreshKey(state: PagingState<String, NotificationRequest>): String? = null |
||||
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, NotificationRequest> { |
||||
return if (params is LoadParams.Refresh) { |
||||
LoadResult.Page(requests.toList(), null, nextKey) |
||||
} else { |
||||
LoadResult.Page(emptyList(), null, null) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,73 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests |
||||
|
||||
import androidx.paging.ExperimentalPagingApi |
||||
import androidx.paging.LoadType |
||||
import androidx.paging.PagingState |
||||
import androidx.paging.RemoteMediator |
||||
import com.keylesspalace.tusky.entity.NotificationRequest |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.util.HttpHeaderLink |
||||
import retrofit2.HttpException |
||||
import retrofit2.Response |
||||
|
||||
@OptIn(ExperimentalPagingApi::class) |
||||
class NotificationRequestsRemoteMediator( |
||||
private val api: MastodonApi, |
||||
private val viewModel: NotificationRequestsViewModel |
||||
) : RemoteMediator<String, NotificationRequest>() { |
||||
|
||||
override suspend fun load( |
||||
loadType: LoadType, |
||||
state: PagingState<String, NotificationRequest> |
||||
): MediatorResult { |
||||
return try { |
||||
val response = request(loadType) |
||||
?: return MediatorResult.Success(endOfPaginationReached = true) |
||||
|
||||
return applyResponse(response) |
||||
} catch (e: Exception) { |
||||
MediatorResult.Error(e) |
||||
} |
||||
} |
||||
|
||||
private suspend fun request(loadType: LoadType): Response<List<NotificationRequest>>? { |
||||
return when (loadType) { |
||||
LoadType.PREPEND -> null |
||||
LoadType.APPEND -> api.getNotificationRequests(maxId = viewModel.nextKey) |
||||
LoadType.REFRESH -> { |
||||
viewModel.nextKey = null |
||||
viewModel.requestData.clear() |
||||
api.getNotificationRequests() |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun applyResponse(response: Response<List<NotificationRequest>>): MediatorResult { |
||||
val notificationRequests = response.body() |
||||
if (!response.isSuccessful || notificationRequests == null) { |
||||
return MediatorResult.Error(HttpException(response)) |
||||
} |
||||
|
||||
val links = HttpHeaderLink.parse(response.headers()["Link"]) |
||||
viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") |
||||
viewModel.requestData.addAll(notificationRequests) |
||||
viewModel.currentSource?.invalidate() |
||||
|
||||
return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) |
||||
} |
||||
} |
||||
@ -0,0 +1,123 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests |
||||
|
||||
import android.util.Log |
||||
import androidx.lifecycle.ViewModel |
||||
import androidx.lifecycle.viewModelScope |
||||
import androidx.paging.ExperimentalPagingApi |
||||
import androidx.paging.Pager |
||||
import androidx.paging.PagingConfig |
||||
import androidx.paging.cachedIn |
||||
import at.connyduck.calladapter.networkresult.fold |
||||
import com.keylesspalace.tusky.appstore.BlockEvent |
||||
import com.keylesspalace.tusky.appstore.EventHub |
||||
import com.keylesspalace.tusky.appstore.MuteEvent |
||||
import com.keylesspalace.tusky.entity.NotificationRequest |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import dagger.hilt.android.lifecycle.HiltViewModel |
||||
import javax.inject.Inject |
||||
import kotlinx.coroutines.channels.BufferOverflow |
||||
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
import kotlinx.coroutines.flow.SharedFlow |
||||
import kotlinx.coroutines.flow.asSharedFlow |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@HiltViewModel |
||||
class NotificationRequestsViewModel @Inject constructor( |
||||
private val api: MastodonApi, |
||||
private val eventHub: EventHub |
||||
) : ViewModel() { |
||||
|
||||
var currentSource: NotificationRequestsPagingSource? = null |
||||
|
||||
val requestData: MutableList<NotificationRequest> = mutableListOf() |
||||
|
||||
var nextKey: String? = null |
||||
|
||||
@OptIn(ExperimentalPagingApi::class) |
||||
val pager = Pager( |
||||
config = PagingConfig( |
||||
pageSize = 20, |
||||
initialLoadSize = 20 |
||||
), |
||||
remoteMediator = NotificationRequestsRemoteMediator(api, this), |
||||
pagingSourceFactory = { |
||||
NotificationRequestsPagingSource( |
||||
requests = requestData, |
||||
nextKey = nextKey |
||||
).also { source -> |
||||
currentSource = source |
||||
} |
||||
} |
||||
).flow |
||||
.cachedIn(viewModelScope) |
||||
|
||||
private val _error = MutableSharedFlow<Throwable>( |
||||
replay = 0, |
||||
extraBufferCapacity = 1, |
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST |
||||
) |
||||
val error: SharedFlow<Throwable> = _error.asSharedFlow() |
||||
|
||||
init { |
||||
viewModelScope.launch { |
||||
eventHub.events |
||||
.collect { event -> |
||||
when (event) { |
||||
is BlockEvent -> removeAllByAccount(event.accountId) |
||||
is MuteEvent -> removeAllByAccount(event.accountId) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun acceptNotificationRequest(id: String) { |
||||
viewModelScope.launch { |
||||
api.acceptNotificationRequest(id).fold({ |
||||
removeNotificationRequest(id) |
||||
}, { error -> |
||||
Log.w(TAG, "failed to dismiss notifications request", error) |
||||
_error.emit(error) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
fun dismissNotificationRequest(id: String) { |
||||
viewModelScope.launch { |
||||
api.dismissNotificationRequest(id).fold({ |
||||
removeNotificationRequest(id) |
||||
}, { error -> |
||||
Log.w(TAG, "failed to dismiss notifications request", error) |
||||
_error.emit(error) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
fun removeNotificationRequest(id: String) { |
||||
requestData.removeAll { request -> request.id == id } |
||||
currentSource?.invalidate() |
||||
} |
||||
|
||||
private fun removeAllByAccount(accountId: String) { |
||||
requestData.removeAll { request -> request.account.id == accountId } |
||||
currentSource?.invalidate() |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "NotificationRequestsViewModel" |
||||
} |
||||
} |
||||
@ -0,0 +1,105 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests.details |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.os.Bundle |
||||
import androidx.activity.viewModels |
||||
import androidx.lifecycle.lifecycleScope |
||||
import com.keylesspalace.tusky.BottomSheetActivity |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.databinding.ActivityNotificationRequestDetailsBinding |
||||
import com.keylesspalace.tusky.entity.Emoji |
||||
import com.keylesspalace.tusky.settings.PrefKeys |
||||
import com.keylesspalace.tusky.util.emojify |
||||
import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat |
||||
import com.keylesspalace.tusky.util.viewBinding |
||||
import dagger.hilt.android.AndroidEntryPoint |
||||
import dagger.hilt.android.lifecycle.withCreationCallback |
||||
import kotlin.getValue |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@AndroidEntryPoint |
||||
class NotificationRequestDetailsActivity : BottomSheetActivity() { |
||||
|
||||
private val viewModel: NotificationRequestDetailsViewModel by viewModels( |
||||
extrasProducer = { |
||||
defaultViewModelCreationExtras.withCreationCallback<NotificationRequestDetailsViewModel.Factory> { factory -> |
||||
factory.create( |
||||
notificationRequestId = intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!, |
||||
accountId = intent.getStringExtra(EXTRA_ACCOUNT_ID)!! |
||||
) |
||||
} |
||||
} |
||||
) |
||||
|
||||
private val binding by viewBinding(ActivityNotificationRequestDetailsBinding::inflate) |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
setContentView(binding.root) |
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar) |
||||
|
||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) |
||||
|
||||
val emojis: List<Emoji> = intent.getParcelableArrayListExtraCompat(EXTRA_ACCOUNT_EMOJIS)!! |
||||
|
||||
val title = getString(R.string.notifications_from, intent.getStringExtra(EXTRA_ACCOUNT_NAME)) |
||||
.emojify(emojis, binding.includedToolbar.toolbar, animateEmojis) |
||||
|
||||
supportActionBar?.run { |
||||
setTitle(title) |
||||
setDisplayHomeAsUpEnabled(true) |
||||
setDisplayShowHomeEnabled(true) |
||||
} |
||||
|
||||
lifecycleScope.launch { |
||||
viewModel.finish.collect { finishMode -> |
||||
setResult(RESULT_OK, Intent().apply { putExtra(EXTRA_NOTIFICATION_REQUEST_ID, intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!) }) |
||||
finish() |
||||
} |
||||
} |
||||
|
||||
binding.acceptButton.setOnClickListener { |
||||
viewModel.acceptNotificationRequest() |
||||
} |
||||
binding.dismissButtin.setOnClickListener { |
||||
viewModel.dismissNotificationRequest() |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
const val EXTRA_NOTIFICATION_REQUEST_ID = "notificationRequestId" |
||||
private const val EXTRA_ACCOUNT_ID = "accountId" |
||||
private const val EXTRA_ACCOUNT_NAME = "accountName" |
||||
private const val EXTRA_ACCOUNT_EMOJIS = "accountEmojis" |
||||
fun newIntent( |
||||
notificationRequestId: String, |
||||
accountId: String, |
||||
accountName: String, |
||||
accountEmojis: List<Emoji>, |
||||
context: Context |
||||
) = Intent(context, NotificationRequestDetailsActivity::class.java).apply { |
||||
putExtra(EXTRA_NOTIFICATION_REQUEST_ID, notificationRequestId) |
||||
putExtra(EXTRA_ACCOUNT_ID, accountId) |
||||
putExtra(EXTRA_ACCOUNT_NAME, accountName) |
||||
putExtra(EXTRA_ACCOUNT_EMOJIS, ArrayList(accountEmojis)) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,296 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests.details |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.content.SharedPreferences |
||||
import android.os.Bundle |
||||
import android.util.Log |
||||
import android.view.View |
||||
import androidx.fragment.app.activityViewModels |
||||
import androidx.lifecycle.lifecycleScope |
||||
import androidx.paging.LoadState |
||||
import androidx.recyclerview.widget.DividerItemDecoration |
||||
import androidx.recyclerview.widget.LinearLayoutManager |
||||
import androidx.recyclerview.widget.SimpleItemAnimator |
||||
import at.connyduck.calladapter.networkresult.onFailure |
||||
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG |
||||
import com.google.android.material.snackbar.Snackbar |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.components.notifications.NotificationActionListener |
||||
import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter |
||||
import com.keylesspalace.tusky.databinding.FragmentNotificationRequestDetailsBinding |
||||
import com.keylesspalace.tusky.fragment.SFragment |
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener |
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener |
||||
import com.keylesspalace.tusky.settings.PrefKeys |
||||
import com.keylesspalace.tusky.util.CardViewMode |
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions |
||||
import com.keylesspalace.tusky.util.getErrorString |
||||
import com.keylesspalace.tusky.util.hide |
||||
import com.keylesspalace.tusky.util.openLink |
||||
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 com.keylesspalace.tusky.viewdata.TranslationViewData |
||||
import dagger.hilt.android.AndroidEntryPoint |
||||
import javax.inject.Inject |
||||
import kotlin.getValue |
||||
import kotlinx.coroutines.flow.collectLatest |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@AndroidEntryPoint |
||||
class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notification_request_details), StatusActionListener, NotificationActionListener, AccountActionListener { |
||||
|
||||
@Inject |
||||
lateinit var preferences: SharedPreferences |
||||
|
||||
private val viewModel: NotificationRequestDetailsViewModel by activityViewModels() |
||||
|
||||
private val binding by viewBinding(FragmentNotificationRequestDetailsBinding::bind) |
||||
|
||||
private var adapter: NotificationsPagingAdapter? = null |
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
||||
super.onViewCreated(view, savedInstanceState) |
||||
|
||||
setupAdapter().let { adapter -> |
||||
this.adapter = adapter |
||||
setupRecyclerView(adapter) |
||||
|
||||
lifecycleScope.launch { |
||||
viewModel.pager.collectLatest { pagingData -> |
||||
adapter.submitData(pagingData) |
||||
} |
||||
} |
||||
} |
||||
|
||||
lifecycleScope.launch { |
||||
viewModel.error.collect { error -> |
||||
Snackbar.make( |
||||
binding.root, |
||||
error.getErrorString(requireContext()), |
||||
LENGTH_LONG |
||||
).show() |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun setupRecyclerView(adapter: NotificationsPagingAdapter) { |
||||
binding.recyclerView.adapter = adapter |
||||
binding.recyclerView.setHasFixedSize(true) |
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) |
||||
binding.recyclerView.addItemDecoration( |
||||
DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL) |
||||
) |
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false |
||||
} |
||||
|
||||
private fun setupAdapter(): NotificationsPagingAdapter { |
||||
val statusDisplayOptions = StatusDisplayOptions( |
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), |
||||
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, |
||||
useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), |
||||
showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), |
||||
useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), |
||||
cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { |
||||
CardViewMode.INDENTED |
||||
} else { |
||||
CardViewMode.NONE |
||||
}, |
||||
confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), |
||||
confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), |
||||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), |
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), |
||||
showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), |
||||
showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, |
||||
openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler |
||||
) |
||||
|
||||
return NotificationsPagingAdapter( |
||||
accountId = accountManager.activeAccount!!.accountId, |
||||
statusDisplayOptions = statusDisplayOptions, |
||||
statusListener = this, |
||||
notificationActionListener = this, |
||||
accountActionListener = this |
||||
).apply { |
||||
addLoadStateListener { loadState -> |
||||
binding.progressBar.visible( |
||||
loadState.refresh == LoadState.Loading && itemCount == 0 |
||||
) |
||||
|
||||
if (loadState.refresh is LoadState.Error) { |
||||
binding.recyclerView.hide() |
||||
binding.statusView.show() |
||||
val errorState = loadState.refresh as LoadState.Error |
||||
binding.statusView.setup(errorState.error) { retry() } |
||||
Log.w(TAG, "error loading notifications for user ${viewModel.accountId}", errorState.error) |
||||
} else { |
||||
binding.recyclerView.show() |
||||
binding.statusView.hide() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onReply(position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
super.reply(status.status) |
||||
} |
||||
|
||||
override fun removeItem(position: Int) { |
||||
val notification = adapter?.peek(position) ?: return |
||||
viewModel.remove(notification) |
||||
} |
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.reblog(reblog, status) |
||||
} |
||||
|
||||
override val onMoreTranslate: ((Boolean, Int) -> Unit)? |
||||
get() = { translate: Boolean, position: Int -> |
||||
if (translate) { |
||||
onTranslate(position) |
||||
} else { |
||||
onUntranslate(position) |
||||
} |
||||
} |
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.favorite(favourite, status) |
||||
} |
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.bookmark(bookmark, status) |
||||
} |
||||
|
||||
override fun onMore(view: View, position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
super.more( |
||||
status.status, |
||||
view, |
||||
position, |
||||
(status.translation as? TranslationViewData.Loaded)?.data |
||||
) |
||||
} |
||||
|
||||
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view) |
||||
} |
||||
|
||||
override fun onViewThread(position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull()?.status ?: return |
||||
super.viewThread(status.id, status.url) |
||||
} |
||||
|
||||
override fun onOpenReblog(position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
super.openReblog(status.status) |
||||
} |
||||
|
||||
override fun onExpandedChange(expanded: Boolean, position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.changeExpanded(expanded, status) |
||||
} |
||||
|
||||
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.changeContentShowing(isShowing, status) |
||||
} |
||||
|
||||
override fun onLoadMore(position: Int) { |
||||
// not applicable here |
||||
} |
||||
|
||||
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.changeContentCollapsed(isCollapsed, status) |
||||
} |
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.voteInPoll(choices, status) |
||||
} |
||||
|
||||
override fun clearWarningAction(position: Int) { |
||||
// not applicable here |
||||
} |
||||
|
||||
private fun onTranslate(position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewLifecycleOwner.lifecycleScope.launch { |
||||
viewModel.translate(status) |
||||
.onFailure { |
||||
Snackbar.make( |
||||
requireView(), |
||||
getString(R.string.ui_error_translate, it.message), |
||||
LENGTH_LONG |
||||
).show() |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onUntranslate(position: Int) { |
||||
val status = adapter?.peek(position)?.asStatusOrNull() ?: return |
||||
viewModel.untranslate(status) |
||||
} |
||||
|
||||
override fun onViewTag(tag: String) { |
||||
super.viewTag(tag) |
||||
} |
||||
|
||||
override fun onViewAccount(id: String) { |
||||
super.viewAccount(id) |
||||
} |
||||
|
||||
override fun onViewReport(reportId: String) { |
||||
requireContext().openLink( |
||||
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" |
||||
) |
||||
} |
||||
|
||||
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { |
||||
// not needed, muting via the more menu on statuses is handled in SFragment |
||||
} |
||||
|
||||
override fun onBlock(block: Boolean, id: String, position: Int) { |
||||
// not needed, blocking via the more menu on statuses is handled in SFragment |
||||
} |
||||
|
||||
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { |
||||
val notification = adapter?.peek(position) ?: return |
||||
viewModel.respondToFollowRequest(accept, accountId = id, notification = notification) |
||||
} |
||||
|
||||
override fun onDestroyView() { |
||||
adapter = null |
||||
super.onDestroyView() |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "NotificationRequestsDetailsFragment" |
||||
private const val EXTRA_ACCOUNT_ID = "accountId" |
||||
fun newIntent(accountId: String, context: Context) = Intent(context, NotificationRequestDetailsActivity::class.java).apply { |
||||
putExtra(EXTRA_ACCOUNT_ID, accountId) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,35 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests.details |
||||
|
||||
import androidx.paging.PagingSource |
||||
import androidx.paging.PagingState |
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData |
||||
|
||||
class NotificationRequestDetailsPagingSource( |
||||
private val notifications: List<NotificationViewData>, |
||||
private val nextKey: String? |
||||
) : PagingSource<String, NotificationViewData>() { |
||||
override fun getRefreshKey(state: PagingState<String, NotificationViewData>): String? = null |
||||
|
||||
override suspend fun load(params: LoadParams<String>): LoadResult<String, NotificationViewData> { |
||||
return if (params is LoadParams.Refresh) { |
||||
LoadResult.Page(notifications.toList(), null, nextKey) |
||||
} else { |
||||
LoadResult.Page(emptyList(), null, null) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,84 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests.details |
||||
|
||||
import androidx.paging.ExperimentalPagingApi |
||||
import androidx.paging.LoadType |
||||
import androidx.paging.PagingState |
||||
import androidx.paging.RemoteMediator |
||||
import com.keylesspalace.tusky.components.notifications.toViewData |
||||
import com.keylesspalace.tusky.entity.Notification |
||||
import com.keylesspalace.tusky.util.HttpHeaderLink |
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData |
||||
import retrofit2.HttpException |
||||
import retrofit2.Response |
||||
|
||||
@OptIn(ExperimentalPagingApi::class) |
||||
class NotificationRequestDetailsRemoteMediator( |
||||
private val viewModel: NotificationRequestDetailsViewModel |
||||
) : RemoteMediator<String, NotificationViewData>() { |
||||
|
||||
override suspend fun load( |
||||
loadType: LoadType, |
||||
state: PagingState<String, NotificationViewData> |
||||
): MediatorResult { |
||||
return try { |
||||
val response = request(loadType) |
||||
?: return MediatorResult.Success(endOfPaginationReached = true) |
||||
|
||||
return applyResponse(response) |
||||
} catch (e: Exception) { |
||||
MediatorResult.Error(e) |
||||
} |
||||
} |
||||
|
||||
private suspend fun request(loadType: LoadType): Response<List<Notification>>? { |
||||
return when (loadType) { |
||||
LoadType.PREPEND -> null |
||||
LoadType.APPEND -> viewModel.api.notifications(maxId = viewModel.nextKey, accountId = viewModel.accountId) |
||||
LoadType.REFRESH -> { |
||||
viewModel.nextKey = null |
||||
viewModel.notificationData.clear() |
||||
viewModel.api.notifications(accountId = viewModel.accountId) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun applyResponse(response: Response<List<Notification>>): MediatorResult { |
||||
val notifications = response.body() |
||||
if (!response.isSuccessful || notifications == null) { |
||||
return MediatorResult.Error(HttpException(response)) |
||||
} |
||||
|
||||
val links = HttpHeaderLink.parse(response.headers()["Link"]) |
||||
viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") |
||||
|
||||
val alwaysShowSensitiveMedia = viewModel.accountManager.activeAccount?.alwaysShowSensitiveMedia == true |
||||
val alwaysOpenSpoiler = viewModel.accountManager.activeAccount?.alwaysOpenSpoiler == false |
||||
val notificationData = notifications.map { notification -> |
||||
notification.toViewData( |
||||
isShowingContent = alwaysShowSensitiveMedia, |
||||
isExpanded = alwaysOpenSpoiler, |
||||
true |
||||
) |
||||
} |
||||
|
||||
viewModel.notificationData.addAll(notificationData) |
||||
viewModel.currentSource?.invalidate() |
||||
|
||||
return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) |
||||
} |
||||
} |
||||
@ -0,0 +1,268 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.notifications.requests.details |
||||
|
||||
import android.util.Log |
||||
import androidx.lifecycle.ViewModel |
||||
import androidx.lifecycle.viewModelScope |
||||
import androidx.paging.ExperimentalPagingApi |
||||
import androidx.paging.Pager |
||||
import androidx.paging.PagingConfig |
||||
import androidx.paging.cachedIn |
||||
import at.connyduck.calladapter.networkresult.NetworkResult |
||||
import at.connyduck.calladapter.networkresult.fold |
||||
import at.connyduck.calladapter.networkresult.map |
||||
import at.connyduck.calladapter.networkresult.onFailure |
||||
import com.keylesspalace.tusky.appstore.BlockEvent |
||||
import com.keylesspalace.tusky.appstore.EventHub |
||||
import com.keylesspalace.tusky.appstore.MuteEvent |
||||
import com.keylesspalace.tusky.appstore.StatusChangedEvent |
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.usecase.TimelineCases |
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData |
||||
import com.keylesspalace.tusky.viewdata.StatusViewData |
||||
import com.keylesspalace.tusky.viewdata.TranslationViewData |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedFactory |
||||
import dagger.assisted.AssistedInject |
||||
import dagger.hilt.android.lifecycle.HiltViewModel |
||||
import kotlinx.coroutines.channels.BufferOverflow |
||||
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
import kotlinx.coroutines.flow.SharedFlow |
||||
import kotlinx.coroutines.flow.asSharedFlow |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@HiltViewModel(assistedFactory = NotificationRequestDetailsViewModel.Factory::class) |
||||
class NotificationRequestDetailsViewModel @AssistedInject constructor( |
||||
val api: MastodonApi, |
||||
val accountManager: AccountManager, |
||||
val timelineCases: TimelineCases, |
||||
val eventHub: EventHub, |
||||
@Assisted("notificationRequestId") val notificationRequestId: String, |
||||
@Assisted("accountId") val accountId: String |
||||
) : ViewModel() { |
||||
|
||||
var currentSource: NotificationRequestDetailsPagingSource? = null |
||||
|
||||
val notificationData: MutableList<NotificationViewData.Concrete> = mutableListOf() |
||||
|
||||
var nextKey: String? = null |
||||
|
||||
@OptIn(ExperimentalPagingApi::class) |
||||
val pager = Pager( |
||||
config = PagingConfig( |
||||
pageSize = 20, |
||||
initialLoadSize = 20 |
||||
), |
||||
remoteMediator = NotificationRequestDetailsRemoteMediator(this), |
||||
pagingSourceFactory = { |
||||
NotificationRequestDetailsPagingSource( |
||||
notifications = notificationData, |
||||
nextKey = nextKey |
||||
).also { source -> |
||||
currentSource = source |
||||
} |
||||
} |
||||
).flow |
||||
.cachedIn(viewModelScope) |
||||
|
||||
private val _error = MutableSharedFlow<Throwable>( |
||||
replay = 0, |
||||
extraBufferCapacity = 1, |
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST |
||||
) |
||||
val error: SharedFlow<Throwable> = _error.asSharedFlow() |
||||
|
||||
private val _finish = MutableSharedFlow<Unit>( |
||||
replay = 0, |
||||
extraBufferCapacity = 1, |
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST |
||||
) |
||||
val finish: SharedFlow<Unit> = _finish.asSharedFlow() |
||||
|
||||
init { |
||||
viewModelScope.launch { |
||||
eventHub.events |
||||
.collect { event -> |
||||
when (event) { |
||||
is StatusChangedEvent -> updateStatus(event.status) |
||||
is BlockEvent -> removeIfAccount(event.accountId) |
||||
is MuteEvent -> removeIfAccount(event.accountId) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun acceptNotificationRequest() { |
||||
viewModelScope.launch { |
||||
api.acceptNotificationRequest(notificationRequestId).fold( |
||||
{ |
||||
_finish.emit(Unit) |
||||
}, |
||||
{ error -> |
||||
Log.w(TAG, "failed to dismiss notifications request", error) |
||||
_error.emit(error) |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun dismissNotificationRequest() { |
||||
viewModelScope.launch { |
||||
api.dismissNotificationRequest(notificationRequestId).fold({ |
||||
_finish.emit(Unit) |
||||
}, { error -> |
||||
Log.w(TAG, "failed to dismiss notifications request", error) |
||||
_error.emit(error) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
private fun updateStatus(status: Status) { |
||||
val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == status.id } |
||||
if (position == -1) { |
||||
return |
||||
} |
||||
val viewData = notificationData[position].statusViewData?.copy(status = status) |
||||
notificationData[position] = notificationData[position].copy(statusViewData = viewData) |
||||
currentSource?.invalidate() |
||||
} |
||||
|
||||
private fun removeIfAccount(accountId: String) { |
||||
// if the account we are displaying notifications from got blocked or muted, we can exit |
||||
if (accountId == this.accountId) { |
||||
viewModelScope.launch { |
||||
_finish.emit(Unit) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun remove(notification: NotificationViewData) { |
||||
notificationData.remove(notification) |
||||
currentSource?.invalidate() |
||||
} |
||||
|
||||
fun reblog(reblog: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch { |
||||
timelineCases.reblog(status.actionableId, reblog).onFailure { t -> |
||||
ifExpected(t) { |
||||
Log.w(TAG, "Failed to reblog status " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun favorite(favorite: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch { |
||||
timelineCases.favourite(status.actionableId, favorite).onFailure { t -> |
||||
ifExpected(t) { |
||||
Log.w(TAG, "Failed to favourite status " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch { |
||||
timelineCases.bookmark(status.actionableId, bookmark).onFailure { t -> |
||||
ifExpected(t) { |
||||
Log.w(TAG, "Failed to favourite status " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { |
||||
updateStatusViewData(status.id) { it.copy(isExpanded = expanded) } |
||||
} |
||||
|
||||
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { |
||||
updateStatusViewData(status.id) { it.copy(isShowingContent = isShowing) } |
||||
} |
||||
|
||||
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { |
||||
updateStatusViewData(status.id) { it.copy(isCollapsed = isCollapsed) } |
||||
} |
||||
|
||||
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete) = viewModelScope.launch { |
||||
val poll = status.status.actionableStatus.poll ?: run { |
||||
Log.w(TAG, "No poll on status ${status.id}") |
||||
return@launch |
||||
} |
||||
timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t -> |
||||
ifExpected(t) { |
||||
Log.w(TAG, "Failed to vote in poll: " + status.actionableId, t) |
||||
} |
||||
} |
||||
} |
||||
|
||||
suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit> { |
||||
updateStatusViewData(status.id) { viewData -> |
||||
viewData.copy(translation = TranslationViewData.Loading) |
||||
} |
||||
return timelineCases.translate(status.actionableId) |
||||
.map { translation -> |
||||
updateStatusViewData(status.id) { viewData -> |
||||
viewData.copy(translation = TranslationViewData.Loaded(translation)) |
||||
} |
||||
} |
||||
.onFailure { |
||||
updateStatusViewData(status.id) { viewData -> |
||||
viewData.copy(translation = null) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun untranslate(status: StatusViewData.Concrete) { |
||||
updateStatusViewData(status.id) { it.copy(translation = null) } |
||||
} |
||||
|
||||
fun respondToFollowRequest(accept: Boolean, accountId: String, notification: NotificationViewData) { |
||||
viewModelScope.launch { |
||||
if (accept) { |
||||
api.authorizeFollowRequest(accountId) |
||||
} else { |
||||
api.rejectFollowRequest(accountId) |
||||
}.fold( |
||||
onSuccess = { |
||||
// since the follow request has been responded, the notification can be deleted |
||||
remove(notification) |
||||
}, |
||||
onFailure = { t -> |
||||
Log.w(TAG, "Failed to to respond to follow request from account id $accountId.", t) |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
private fun updateStatusViewData( |
||||
statusId: String, |
||||
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete |
||||
) { |
||||
val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == statusId } |
||||
val statusViewData = notificationData.getOrNull(position)?.statusViewData ?: return |
||||
notificationData[position] = notificationData[position].copy(statusViewData = updater(statusViewData)) |
||||
currentSource?.invalidate() |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "NotificationRequestsViewModel" |
||||
} |
||||
|
||||
@AssistedFactory interface Factory { |
||||
fun create( |
||||
@Assisted("notificationRequestId") notificationRequestId: String, |
||||
@Assisted("accountId") accountId: String |
||||
): NotificationRequestDetailsViewModel |
||||
} |
||||
} |
||||
@ -0,0 +1,87 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.preference.notificationpolicies |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.os.Bundle |
||||
import androidx.activity.viewModels |
||||
import androidx.lifecycle.lifecycleScope |
||||
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG |
||||
import com.google.android.material.snackbar.Snackbar |
||||
import com.keylesspalace.tusky.BaseActivity |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.databinding.ActivityNotificationPolicyBinding |
||||
import com.keylesspalace.tusky.usecase.NotificationPolicyState |
||||
import com.keylesspalace.tusky.util.getErrorString |
||||
import com.keylesspalace.tusky.util.viewBinding |
||||
import com.keylesspalace.tusky.util.visible |
||||
import dagger.hilt.android.AndroidEntryPoint |
||||
import kotlin.getValue |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@AndroidEntryPoint |
||||
class NotificationPoliciesActivity : BaseActivity() { |
||||
|
||||
private val viewModel: NotificationPoliciesViewModel by viewModels() |
||||
|
||||
private val binding by viewBinding(ActivityNotificationPolicyBinding::inflate) |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
|
||||
setContentView(binding.root) |
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar) |
||||
supportActionBar?.run { |
||||
setTitle(R.string.notification_policies_title) |
||||
setDisplayHomeAsUpEnabled(true) |
||||
setDisplayShowHomeEnabled(true) |
||||
} |
||||
|
||||
lifecycleScope.launch { |
||||
viewModel.state.collect { state -> |
||||
binding.progressBar.visible(state is NotificationPolicyState.Loading) |
||||
binding.preferenceFragment.visible(state is NotificationPolicyState.Loaded) |
||||
binding.messageView.visible(state !is NotificationPolicyState.Loading && state !is NotificationPolicyState.Loaded) |
||||
when (state) { |
||||
is NotificationPolicyState.Loading -> { } |
||||
|
||||
is NotificationPolicyState.Error -> |
||||
binding.messageView.setup(state.throwable) { viewModel.loadPolicy() } |
||||
|
||||
is NotificationPolicyState.Loaded -> { } |
||||
|
||||
NotificationPolicyState.Unsupported -> |
||||
binding.messageView.setup(R.drawable.errorphant_error, R.string.notification_policies_not_supported) { viewModel.loadPolicy() } |
||||
} |
||||
} |
||||
} |
||||
lifecycleScope.launch { |
||||
viewModel.error.collect { error -> |
||||
Snackbar.make( |
||||
binding.root, |
||||
error.getErrorString(this@NotificationPoliciesActivity), |
||||
LENGTH_LONG |
||||
).show() |
||||
} |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
fun newIntent(context: Context) = Intent(context, NotificationPoliciesActivity::class.java) |
||||
} |
||||
} |
||||
@ -0,0 +1,119 @@
|
||||
/* Copyright 2024 Tusky contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.preference.notificationpolicies |
||||
|
||||
import android.os.Bundle |
||||
import android.view.View |
||||
import androidx.fragment.app.activityViewModels |
||||
import androidx.lifecycle.lifecycleScope |
||||
import androidx.preference.PreferenceFragmentCompat |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.settings.makePreferenceScreen |
||||
import com.keylesspalace.tusky.settings.preferenceCategory |
||||
import com.keylesspalace.tusky.usecase.NotificationPolicyState |
||||
import dagger.hilt.android.AndroidEntryPoint |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@AndroidEntryPoint |
||||
class NotificationPoliciesFragment : PreferenceFragmentCompat() { |
||||
|
||||
val viewModel: NotificationPoliciesViewModel by activityViewModels() |
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { |
||||
makePreferenceScreen { |
||||
preferenceCategory(title = R.string.notification_policies_filter_out) { category -> |
||||
category.isIconSpaceReserved = false |
||||
|
||||
notificationPolicyPreference { |
||||
setTitle(R.string.notification_policies_filter_dont_follow_title) |
||||
setSummary(R.string.notification_policies_filter_dont_follow_description) |
||||
key = KEY_NOT_FOLLOWING |
||||
setOnPreferenceChangeListener { _, newValue -> |
||||
viewModel.updatePolicy(forNotFollowing = newValue as String) |
||||
true |
||||
} |
||||
} |
||||
|
||||
notificationPolicyPreference { |
||||
setTitle(R.string.notification_policies_filter_not_following_title) |
||||
setSummary(R.string.notification_policies_filter_not_following_description) |
||||
key = KEY_NOT_FOLLOWERS |
||||
setOnPreferenceChangeListener { _, newValue -> |
||||
viewModel.updatePolicy(forNotFollowers = newValue as String) |
||||
true |
||||
} |
||||
} |
||||
|
||||
notificationPolicyPreference { |
||||
setTitle(R.string.unknown_notification_filter_new_accounts_title) |
||||
setSummary(R.string.unknown_notification_filter_new_accounts_description) |
||||
key = KEY_NEW_ACCOUNTS |
||||
setOnPreferenceChangeListener { _, newValue -> |
||||
viewModel.updatePolicy(forNewAccounts = newValue as String) |
||||
true |
||||
} |
||||
} |
||||
|
||||
notificationPolicyPreference { |
||||
setTitle(R.string.unknown_notification_filter_unsolicited_private_mentions_title) |
||||
setSummary(R.string.unknown_notification_filter_unsolicited_private_mentions_description) |
||||
key = KEY_PRIVATE_MENTIONS |
||||
setOnPreferenceChangeListener { _, newValue -> |
||||
viewModel.updatePolicy(forPrivateMentions = newValue as String) |
||||
true |
||||
} |
||||
} |
||||
|
||||
notificationPolicyPreference { |
||||
setTitle(R.string.unknown_notification_filter_moderated_accounts) |
||||
setSummary(R.string.unknown_notification_filter_moderated_accounts_description) |
||||
key = KEY_LIMITED_ACCOUNTS |
||||
setOnPreferenceChangeListener { _, newValue -> |
||||
viewModel.updatePolicy(forLimitedAccounts = newValue as String) |
||||
true |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
||||
super.onViewCreated(view, savedInstanceState) |
||||
viewLifecycleOwner.lifecycleScope.launch { |
||||
viewModel.state.collect { state -> |
||||
if (state is NotificationPolicyState.Loaded) { |
||||
findPreference<NotificationPolicyPreference>(KEY_NOT_FOLLOWING)?.value = state.policy.forNotFollowing.name.lowercase() |
||||
findPreference<NotificationPolicyPreference>(KEY_NOT_FOLLOWERS)?.value = state.policy.forNotFollowers.name.lowercase() |
||||
findPreference<NotificationPolicyPreference>(KEY_NEW_ACCOUNTS)?.value = state.policy.forNewAccounts.name.lowercase() |
||||
findPreference<NotificationPolicyPreference>(KEY_PRIVATE_MENTIONS)?.value = state.policy.forPrivateMentions.name.lowercase() |
||||
findPreference<NotificationPolicyPreference>(KEY_LIMITED_ACCOUNTS)?.value = state.policy.forLimitedAccounts.name.lowercase() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
fun newInstance(): NotificationPoliciesFragment { |
||||
return NotificationPoliciesFragment() |
||||
} |
||||
|
||||
private const val KEY_NOT_FOLLOWING = "NOT_FOLLOWING" |
||||
private const val KEY_NOT_FOLLOWERS = "NOT_FOLLOWERS" |
||||
private const val KEY_NEW_ACCOUNTS = "NEW_ACCOUNTS" |
||||
private const val KEY_PRIVATE_MENTIONS = "PRIVATE MENTIONS" |
||||
private const val KEY_LIMITED_ACCOUNTS = "LIMITED_ACCOUNTS" |
||||
} |
||||
} |
||||
@ -0,0 +1,81 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.preference.notificationpolicies |
||||
|
||||
import android.util.Log |
||||
import androidx.lifecycle.ViewModel |
||||
import androidx.lifecycle.viewModelScope |
||||
import at.connyduck.calladapter.networkresult.onFailure |
||||
import com.keylesspalace.tusky.usecase.NotificationPolicyState |
||||
import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase |
||||
import dagger.hilt.android.lifecycle.HiltViewModel |
||||
import javax.inject.Inject |
||||
import kotlinx.coroutines.channels.BufferOverflow |
||||
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
import kotlinx.coroutines.flow.SharedFlow |
||||
import kotlinx.coroutines.flow.StateFlow |
||||
import kotlinx.coroutines.flow.asSharedFlow |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@HiltViewModel |
||||
class NotificationPoliciesViewModel @Inject constructor( |
||||
private val usecase: NotificationPolicyUsecase |
||||
) : ViewModel() { |
||||
|
||||
val state: StateFlow<NotificationPolicyState> = usecase.state |
||||
|
||||
private val _error = MutableSharedFlow<Throwable>( |
||||
replay = 0, |
||||
extraBufferCapacity = 1, |
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST |
||||
) |
||||
val error: SharedFlow<Throwable> = _error.asSharedFlow() |
||||
|
||||
init { |
||||
loadPolicy() |
||||
} |
||||
|
||||
fun loadPolicy() { |
||||
viewModelScope.launch { |
||||
usecase.getNotificationPolicy() |
||||
} |
||||
} |
||||
|
||||
fun updatePolicy( |
||||
forNotFollowing: String? = null, |
||||
forNotFollowers: String? = null, |
||||
forNewAccounts: String? = null, |
||||
forPrivateMentions: String? = null, |
||||
forLimitedAccounts: String? = null |
||||
) { |
||||
viewModelScope.launch { |
||||
usecase.updatePolicy( |
||||
forNotFollowing = forNotFollowing, |
||||
forNotFollowers = forNotFollowers, |
||||
forNewAccounts = forNewAccounts, |
||||
forPrivateMentions = forPrivateMentions, |
||||
forLimitedAccounts = forLimitedAccounts |
||||
).onFailure { error -> |
||||
Log.w(TAG, "failed to update notifications policy", error) |
||||
_error.emit(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "NotificationPoliciesViewModel" |
||||
} |
||||
} |
||||
@ -0,0 +1,48 @@
|
||||
/* Copyright 2024 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.preference.notificationpolicies |
||||
|
||||
import android.content.Context |
||||
import android.widget.TextView |
||||
import androidx.preference.ListPreference |
||||
import androidx.preference.PreferenceViewHolder |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.settings.PreferenceParent |
||||
|
||||
class NotificationPolicyPreference( |
||||
context: Context |
||||
) : ListPreference(context) { |
||||
|
||||
init { |
||||
widgetLayoutResource = R.layout.preference_notification_policy |
||||
setEntries(R.array.notification_policy_options) |
||||
setEntryValues(R.array.notification_policy_value) |
||||
isIconSpaceReserved = false |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) { |
||||
super.onBindViewHolder(holder) |
||||
val switchView: TextView = holder.findViewById(R.id.notification_policy_value) as TextView |
||||
switchView.text = entries.getOrNull(findIndexOfValue(value)) |
||||
} |
||||
} |
||||
|
||||
inline fun PreferenceParent.notificationPolicyPreference(builder: NotificationPolicyPreference.() -> Unit): NotificationPolicyPreference { |
||||
val pref = NotificationPolicyPreference(context) |
||||
builder(pref) |
||||
addPref(pref) |
||||
return pref |
||||
} |
||||
@ -0,0 +1,47 @@
|
||||
/* Copyright 2024 Tusky contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.entity |
||||
|
||||
import com.squareup.moshi.Json |
||||
import com.squareup.moshi.JsonClass |
||||
|
||||
@JsonClass(generateAdapter = true) |
||||
data class NotificationPolicy( |
||||
@Json(name = "for_not_following") val forNotFollowing: State, |
||||
@Json(name = "for_not_followers") val forNotFollowers: State, |
||||
@Json(name = "for_new_accounts") val forNewAccounts: State, |
||||
@Json(name = "for_private_mentions") val forPrivateMentions: State, |
||||
@Json(name = "for_limited_accounts") val forLimitedAccounts: State, |
||||
val summary: Summary |
||||
) { |
||||
@JsonClass(generateAdapter = false) |
||||
enum class State { |
||||
@Json(name = "accept") |
||||
ACCEPT, |
||||
|
||||
@Json(name = "filter") |
||||
FILTER, |
||||
|
||||
@Json(name = "drop") |
||||
DROP |
||||
} |
||||
|
||||
@JsonClass(generateAdapter = true) |
||||
data class Summary( |
||||
@Json(name = "pending_requests_count") val pendingRequestsCount: Int, |
||||
@Json(name = "pending_notifications_count") val pendingNotificationsCount: Int |
||||
) |
||||
} |
||||
@ -0,0 +1,26 @@
|
||||
/* Copyright 2024 Tusky contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the |
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even |
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General |
||||
* Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.entity |
||||
|
||||
import com.squareup.moshi.Json |
||||
import com.squareup.moshi.JsonClass |
||||
|
||||
@JsonClass(generateAdapter = true) |
||||
data class NotificationRequest( |
||||
val id: String, |
||||
val account: Account, |
||||
@Json(name = "notifications_count") val notificationsCount: String |
||||
) |
||||
@ -0,0 +1,74 @@
|
||||
package com.keylesspalace.tusky.usecase |
||||
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult |
||||
import at.connyduck.calladapter.networkresult.fold |
||||
import at.connyduck.calladapter.networkresult.onSuccess |
||||
import com.keylesspalace.tusky.entity.NotificationPolicy |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import javax.inject.Inject |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import kotlinx.coroutines.flow.StateFlow |
||||
import kotlinx.coroutines.flow.asStateFlow |
||||
import retrofit2.HttpException |
||||
|
||||
class NotificationPolicyUsecase @Inject constructor( |
||||
private val api: MastodonApi |
||||
) { |
||||
|
||||
private val _state: MutableStateFlow<NotificationPolicyState> = MutableStateFlow(NotificationPolicyState.Loading) |
||||
val state: StateFlow<NotificationPolicyState> = _state.asStateFlow() |
||||
|
||||
suspend fun getNotificationPolicy() { |
||||
_state.value.let { state -> |
||||
if (state is NotificationPolicyState.Loaded) { |
||||
_state.value = state.copy(refreshing = true) |
||||
} else { |
||||
_state.value = NotificationPolicyState.Loading |
||||
} |
||||
} |
||||
|
||||
api.notificationPolicy().fold( |
||||
{ policy -> |
||||
_state.value = NotificationPolicyState.Loaded(refreshing = false, policy = policy) |
||||
}, |
||||
{ t -> |
||||
if (t is HttpException && t.code() == 404) { |
||||
_state.value = NotificationPolicyState.Unsupported |
||||
} else { |
||||
_state.value = NotificationPolicyState.Error(t) |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
suspend fun updatePolicy( |
||||
forNotFollowing: String? = null, |
||||
forNotFollowers: String? = null, |
||||
forNewAccounts: String? = null, |
||||
forPrivateMentions: String? = null, |
||||
forLimitedAccounts: String? = null |
||||
): NetworkResult<NotificationPolicy> { |
||||
return api.updateNotificationPolicy( |
||||
forNotFollowing = forNotFollowing, |
||||
forNotFollowers = forNotFollowers, |
||||
forNewAccounts = forNewAccounts, |
||||
forPrivateMentions = forPrivateMentions, |
||||
forLimitedAccounts = forLimitedAccounts |
||||
).onSuccess { notificationPolicy -> |
||||
_state.value = NotificationPolicyState.Loaded(false, notificationPolicy) |
||||
} |
||||
} |
||||
} |
||||
|
||||
sealed interface NotificationPolicyState { |
||||
|
||||
data object Loading : NotificationPolicyState |
||||
data object Unsupported : NotificationPolicyState |
||||
data class Error( |
||||
val throwable: Throwable |
||||
) : NotificationPolicyState |
||||
data class Loaded( |
||||
val refreshing: Boolean, |
||||
val policy: NotificationPolicy |
||||
) : NotificationPolicyState |
||||
} |
||||
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:shape="rectangle"> |
||||
<corners android:radius="6dp" /> |
||||
<solid android:color="?attr/colorPrimary" /> |
||||
</shape> |
||||
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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="match_parent" |
||||
tools:context=".components.preference.notificationpolicies.NotificationPoliciesActivity"> |
||||
|
||||
<include |
||||
android:id="@+id/includedToolbar" |
||||
layout="@layout/toolbar_basic" /> |
||||
|
||||
<androidx.fragment.app.FragmentContainerView |
||||
android:id="@+id/preferenceFragment" |
||||
android:name="com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesFragment" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> |
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView |
||||
android:id="@+id/messageView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
tools:src="@drawable/errorphant_error" /> |
||||
|
||||
<ProgressBar |
||||
android:id="@+id/progressBar" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" /> |
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
||||
@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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="match_parent" |
||||
tools:context="com.keylesspalace.tusky.components.accountlist.AccountListActivity"> |
||||
|
||||
<include |
||||
android:id="@+id/includedToolbar" |
||||
layout="@layout/toolbar_basic" /> |
||||
|
||||
<androidx.fragment.app.FragmentContainerView |
||||
android:id="@+id/fragmentContainer" |
||||
android:name="com.keylesspalace.tusky.components.notifications.requests.details.NotificationRequestDetailsFragment" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> |
||||
|
||||
<LinearLayout |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="bottom" |
||||
android:background="?attr/colorSurface"> |
||||
|
||||
<Button |
||||
android:id="@+id/acceptButton" |
||||
style="@style/TuskyButton" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginVertical="8dp" |
||||
android:layout_marginStart="16dp" |
||||
android:layout_marginEnd="8dp" |
||||
android:layout_weight="1" |
||||
android:text="@string/action_accept_notification_request" /> |
||||
|
||||
<Button |
||||
android:id="@+id/dismissButtin" |
||||
style="@style/TuskyButton" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginVertical="8dp" |
||||
android:layout_marginStart="8dp" |
||||
android:layout_marginEnd="16dp" |
||||
android:layout_weight="1" |
||||
android:text="@string/action_dismiss_notification_request" /> |
||||
|
||||
</LinearLayout> |
||||
|
||||
<include layout="@layout/item_status_bottom_sheet" /> |
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
||||
@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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="match_parent" |
||||
tools:context="com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity"> |
||||
|
||||
<include |
||||
android:id="@+id/includedToolbar" |
||||
layout="@layout/toolbar_basic" /> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/notificationRequestsView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:clipToPadding="false" |
||||
android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton" |
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" |
||||
tools:itemCount="5" |
||||
tools:listitem="@layout/item_notification_request" /> |
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView |
||||
android:id="@+id/notificationRequestsMessageView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="center" |
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" |
||||
tools:visibility="gone" /> |
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator |
||||
android:id="@+id/notificationRequestsProgressBar" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="center" |
||||
android:indeterminate="true" |
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" |
||||
tools:visibility="gone" /> |
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
||||
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent"> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/recyclerView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:clipToPadding="false" /> |
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView |
||||
android:id="@+id/statusView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:visibility="gone" /> |
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator |
||||
android:id="@+id/progressBar" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_gravity="center" |
||||
android:indeterminate="true" /> |
||||
|
||||
</FrameLayout> |
||||
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<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:background="?attr/selectableItemBackground" |
||||
android:paddingHorizontal="14dp" |
||||
android:paddingVertical="16dp"> |
||||
|
||||
<TextView |
||||
android:id="@+id/notification_policy_summary_title" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:text="@string/filtered_notifications" |
||||
android:textColor="?android:textColorSecondary" |
||||
android:textStyle="normal|bold" |
||||
app:layout_constraintEnd_toStartOf="@id/notification_policy_summary_badge" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/notification_policy_summary_description" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:textColor="?android:textColorSecondary" |
||||
app:layout_constraintEnd_toStartOf="@id/notification_policy_summary_badge" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@id/notification_policy_summary_title" |
||||
tools:text="@string/notifications_from_people_you_may_know" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/notification_policy_summary_badge" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:background="@drawable/badge_background" |
||||
android:paddingHorizontal="8dp" |
||||
android:paddingVertical="4dp" |
||||
android:textColor="?attr/colorOnPrimary" |
||||
android:textStyle="normal|bold" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
tools:text="24" /> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
||||
@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<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:paddingStart="16dp" |
||||
android:paddingTop="10dp" |
||||
android:paddingEnd="16dp" |
||||
android:paddingBottom="10dp"> |
||||
|
||||
<ImageView |
||||
android:id="@+id/notificationRequestAvatar" |
||||
android:layout_width="48dp" |
||||
android:layout_height="48dp" |
||||
android:layout_centerVertical="true" |
||||
android:importantForAccessibility="no" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
tools:src="@drawable/avatar_default" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/notificationRequestBadge" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:background="@drawable/badge_background" |
||||
android:contentDescription="@string/profile_badge_bot_text" |
||||
android:paddingHorizontal="8dp" |
||||
android:textAlignment="center" |
||||
android:textColor="?attr/colorOnPrimary" |
||||
android:textStyle="bold" |
||||
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar" |
||||
app:layout_constraintEnd_toEndOf="@id/notificationRequestAvatar" |
||||
tools:text="2" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/notificationRequestDisplayName" |
||||
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_constrainedWidth="true" |
||||
app:layout_constraintBottom_toTopOf="@id/notificationRequestUsername" |
||||
app:layout_constraintEnd_toStartOf="@id/notificationRequestDismiss" |
||||
app:layout_constraintStart_toEndOf="@id/notificationRequestAvatar" |
||||
app:layout_constraintTop_toTopOf="@id/notificationRequestAvatar" |
||||
tools:text="Display name" /> |
||||
|
||||
<TextView |
||||
android:id="@+id/notificationRequestUsername" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:ellipsize="end" |
||||
android:maxLines="1" |
||||
android:textColor="?android:textColorSecondary" |
||||
android:textSize="?attr/status_text_medium" |
||||
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar" |
||||
app:layout_constraintEnd_toEndOf="@id/notificationRequestDisplayName" |
||||
app:layout_constraintStart_toStartOf="@id/notificationRequestDisplayName" |
||||
app:layout_constraintTop_toBottomOf="@id/notificationRequestDisplayName" |
||||
tools:text="\@username" /> |
||||
|
||||
<ImageButton |
||||
android:id="@+id/notificationRequestDismiss" |
||||
style="@style/TuskyImageButton" |
||||
android:layout_width="48dp" |
||||
android:layout_height="48dp" |
||||
android:layout_marginStart="12dp" |
||||
android:background="?attr/selectableItemBackgroundBorderless" |
||||
android:contentDescription="@string/dismiss_notification_request" |
||||
android:padding="4dp" |
||||
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar" |
||||
app:layout_constraintEnd_toStartOf="@id/notificationRequestAccept" |
||||
app:layout_constraintTop_toTopOf="@id/notificationRequestAvatar" |
||||
app:srcCompat="@drawable/ic_clear_24dp" /> |
||||
|
||||
<ImageButton |
||||
android:id="@+id/notificationRequestAccept" |
||||
style="@style/TuskyImageButton" |
||||
android:layout_width="48dp" |
||||
android:layout_height="48dp" |
||||
android:layout_marginStart="12dp" |
||||
android:background="?attr/selectableItemBackgroundBorderless" |
||||
android:contentDescription="@string/accept_notification_request" |
||||
android:padding="4dp" |
||||
app:layout_constraintBottom_toBottomOf="@id/notificationRequestAvatar" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintTop_toTopOf="@id/notificationRequestAvatar" |
||||
app:srcCompat="@drawable/ic_check_24dp" /> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
||||
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:id="@+id/notification_policy_value" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:textStyle="bold" /> |
||||
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto"> |
||||
<item |
||||
android:id="@+id/open_settings" |
||||
android:title="@string/open_settings" |
||||
app:showAsAction="ifRoom" /> |
||||
</menu> |
||||
Loading…
Reference in new issue