This commit is contained in:
Philipp Heckel 2026-01-26 22:07:55 -05:00
parent 20d7e8ca92
commit 0599fefb4b
4 changed files with 93 additions and 93 deletions

View file

@ -575,9 +575,9 @@ interface NotificationDao {
fun listFlow(subscriptionId: Long): Flow<List<Notification>>
@Query("""
SELECT * FROM notification
WHERE subscriptionId = :subscriptionId
AND deleted != 1
SELECT * FROM notification
WHERE subscriptionId = :subscriptionId
AND deleted != 1
AND (
title LIKE '%' || :query || '%' COLLATE NOCASE
OR message LIKE '%' || :query || '%' COLLATE NOCASE

View file

@ -64,6 +64,7 @@ import androidx.core.net.toUri
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import android.widget.ImageButton
import com.google.android.material.color.MaterialColors
class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSettingsListener, PublishFragment.PublishListener {
private val viewModel by viewModels<DetailViewModel> {
@ -148,16 +149,17 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
val dynamicColors = repository.getDynamicColorsEnabled()
val darkMode = isDarkThemeOn(this)
val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode)
toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
toolbarLayout.setBackgroundColor(statusBarColor)
// Set collapse icon (back arrow when search is expanded) with proper tint
toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
val collapseIcon = ContextCompat.getDrawable(this, R.drawable.ic_arrow_back_white_24dp)?.mutate()
collapseIcon?.setTint(toolbarTextColor)
toolbar = toolbarLayout.findViewById(R.id.toolbar)
toolbar.setTitleTextColor(toolbarTextColor)
toolbar.setNavigationIconTint(toolbarTextColor)
toolbar.overflowIcon?.setTint(toolbarTextColor)
// Set collapse icon (back arrow when search is expanded) with proper tint
val collapseIcon = ContextCompat.getDrawable(this, R.drawable.ic_arrow_back_white_24dp)?.mutate()
collapseIcon?.setTint(toolbarTextColor)
toolbar.collapseIcon = collapseIcon
setSupportActionBar(toolbar)
@ -169,7 +171,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
val detailContentLayout = findViewById<View>(R.id.detail_content_layout)
if (repository.getDynamicColorsEnabled()) {
detailContentLayout.setBackgroundColor(
com.google.android.material.color.MaterialColors.getColor(
MaterialColors.getColor(
this,
android.R.attr.colorBackground,
ContextCompat.getColor(this, R.color.detail_activity_background)
@ -291,9 +293,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
// Set "how to instructions"
val howToExample: TextView = findViewById(R.id.detail_how_to_example)
howToExample.linksClickable = true
val howToText = getString(R.string.detail_how_to_example, topicUrl)
howToExample.linksClickable = true
howToExample.text = Html.fromHtml(howToText, Html.FROM_HTML_MODE_LEGACY)
// Swipe to refresh
@ -321,8 +322,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
// Observe filtered notifications (filtered by search query)
val noSearchResultsText: TextView = findViewById(R.id.detail_no_notifications_text)
val howToIntro: View = findViewById(R.id.detail_how_to_intro)
val howToExampleView: View = findViewById(R.id.detail_how_to_example)
val howToLinkView: View = findViewById(R.id.detail_how_to_link)
val howToLink: View = findViewById(R.id.detail_how_to_link)
viewModel.listFiltered(subscriptionId).observe(this) {
it?.let { notifications ->
// Show list view
@ -331,18 +331,16 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
mainListContainer.visibility = View.GONE
noEntriesText.visibility = View.VISIBLE
// Show different text based on whether we're searching or not
if (isSearchActive && viewModel.searchQuery.value?.isNotEmpty() == true) {
if (isSearchActive && viewModel.hasSearchQuery()) {
noSearchResultsText.text = getString(R.string.detail_no_search_results)
// Hide "how to" instructions when showing search results
howToIntro.visibility = View.GONE
howToExampleView.visibility = View.GONE
howToLinkView.visibility = View.GONE
howToExample.visibility = View.GONE
howToLink.visibility = View.GONE
} else {
noSearchResultsText.text = getString(R.string.detail_no_notifications_text)
// Show "how to" instructions for empty subscription
howToIntro.visibility = View.VISIBLE
howToExampleView.visibility = View.VISIBLE
howToLinkView.visibility = if (BuildConfig.PAYMENT_LINKS_AVAILABLE) View.VISIBLE else View.GONE
howToExample.visibility = View.VISIBLE
howToLink.visibility = if (BuildConfig.PAYMENT_LINKS_AVAILABLE) View.VISIBLE else View.GONE
}
} else {
mainListContainer.visibility = View.VISIBLE
@ -393,8 +391,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
}
// Observe connection details and update menu item visibility
repository.getConnectionDetailsLiveData().observe(this) { details ->
showHideConnectionErrorMenuItem(details)
repository.getConnectionDetailsLiveData().observe(this) {
showHideConnectionErrorMenuItem()
}
// Mark this subscription as "open" so we don't receive notifications for it
@ -540,9 +538,9 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
subscriptionMutedUntil = subscription.mutedUntil
subscriptionDisplayName = displayName(appBaseUrl, subscription)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscription.baseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
showHideMutedUntilMenuItems()
showHideCopyMenuItems()
showHideConnectionErrorMenuItem()
updateTitle(subscriptionDisplayName)
}
}
@ -575,24 +573,29 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
menuInflater.inflate(R.menu.menu_detail_action_bar, menu)
this.menu = menu
// Tint menu icons based on theme
for (i in 0 until menu.size) {
menu[i].icon?.setTint(toolbarTextColor)
}
setupSearchView()
showHideMenuItems()
// Setup SearchView
// Regularly check if "notification muted" time has passed
// NOTE: This is done here, because then we know that we've initialized the menu items.
startNotificationMutedChecker()
return true
}
private fun setupSearchView() {
val searchItem = menu.findItem(R.id.detail_menu_search)
searchView = searchItem?.actionView as? SearchView
searchView?.let { sv ->
sv.queryHint = getString(R.string.detail_search_hint)
sv.queryHint = getString(R.string.detail_menu_search_hint)
sv.maxWidth = Integer.MAX_VALUE // Make SearchView expand fully
// Tint SearchView icons and text
val searchIcon = sv.findViewById<ImageView>(androidx.appcompat.R.id.search_button)
searchIcon?.setColorFilter(toolbarTextColor)
val closeIcon = sv.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
closeIcon?.setColorFilter(toolbarTextColor)
val searchEditText = sv.findViewById<EditText>(androidx.appcompat.R.id.search_src_text)
searchIcon?.setColorFilter(toolbarTextColor)
closeIcon?.setColorFilter(toolbarTextColor)
searchEditText?.setTextColor(toolbarTextColor)
searchEditText?.setHintTextColor(toolbarTextColor.and(0x80FFFFFF.toInt())) // 50% alpha
@ -612,48 +615,45 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
isSearchActive = true
// Hide other menu items when search is expanded
setMenuItemsVisibility(false)
// Ensure collapse icon is tinted (back arrow when search is expanded)
toolbar.collapseIcon?.setTint(toolbarTextColor)
showHideMenuItems()
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
isSearchActive = false
// Clear search query when collapsed
viewModel.setSearchQuery("")
// Restore menu items visibility
setMenuItemsVisibility(true)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscriptionBaseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
isSearchActive = false
showHideMenuItems()
return true
}
})
}
// Show and hide buttons
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscriptionBaseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
// Regularly check if "notification muted" time has passed
// NOTE: This is done here, because then we know that we've initialized the menu items.
startNotificationMutedChecker()
return true
}
private fun setMenuItemsVisibility(visible: Boolean) {
private fun showHideMenuItems() {
if (!this::menu.isInitialized) return
// Hide/show all menu items except search
// Tint menu icons based on theme
for (i in 0 until menu.size) {
val item = menu[i]
if (item.itemId != R.id.detail_menu_search) {
item.isVisible = visible
}
menu[i].icon?.setTint(toolbarTextColor)
}
// Ensure collapse icon is tinted (back arrow when search is expanded)
toolbar.collapseIcon?.setTint(toolbarTextColor)
// Show/hide menu items based on state
showHideMutedUntilMenuItems()
showHideCopyMenuItems()
showHideConnectionErrorMenuItem()
showHideOtherMenuItems()
}
private fun showHideOtherMenuItems() {
if (!this::menu.isInitialized) return
runOnUiThread {
menu.findItem(R.id.detail_menu_settings)?.isVisible = !isSearchActive
menu.findItem(R.id.detail_menu_clear)?.isVisible = !isSearchActive
menu.findItem(R.id.detail_menu_test)?.isVisible = !isSearchActive
menu.findItem(R.id.detail_menu_unsubscribe)?.isVisible = !isSearchActive
}
}
@ -669,7 +669,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
if (mutedUntilExpired) {
val newSubscription = subscription.copy(mutedUntil = 0L)
repository.updateSubscription(newSubscription)
showHideMutedUntilMenuItems(0L)
subscriptionMutedUntil = 0L
showHideMutedUntilMenuItems()
}
delay(60_000)
}
@ -776,7 +777,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp)
newSubscription?.let { repository.updateSubscription(newSubscription) }
subscriptionMutedUntil = mutedUntilTimestamp
showHideMutedUntilMenuItems(mutedUntilTimestamp)
showHideMutedUntilMenuItems()
runOnUiThread {
when (mutedUntilTimestamp) {
0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
@ -827,46 +828,44 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
}
}
private fun showHideMutedUntilMenuItems(mutedUntilTimestamp: Long) {
if (!this::menu.isInitialized) {
return
}
subscriptionMutedUntil = mutedUntilTimestamp
private fun showHideMutedUntilMenuItems() {
if (!this::menu.isInitialized) return
runOnUiThread {
val notificationsEnabledItem = menu.findItem(R.id.detail_menu_notifications_enabled)
val notificationsDisabledUntilItem = menu.findItem(R.id.detail_menu_notifications_disabled_until)
val notificationsDisabledForeverItem = menu.findItem(R.id.detail_menu_notifications_disabled_forever)
notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L
notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L
notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L
if (subscriptionMutedUntil > 1L) {
val formattedDate = formatDateShort(subscriptionMutedUntil)
notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate)
if (isSearchActive) {
notificationsEnabledItem?.isVisible = false
notificationsDisabledUntilItem?.isVisible = false
notificationsDisabledForeverItem?.isVisible = false
} else {
notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L
notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L
notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L
if (subscriptionMutedUntil > 1L) {
val formattedDate = formatDateShort(subscriptionMutedUntil)
notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate)
}
}
}
}
private fun showHideCopyMenuItems(subscriptionBaseUrl: String) {
if (!this::menu.isInitialized) {
return
}
private fun showHideCopyMenuItems() {
if (!this::menu.isInitialized) return
runOnUiThread {
// Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463
val copyUrlItem = menu.findItem(R.id.detail_menu_copy_url)
copyUrlItem?.isVisible = appBaseUrl != subscriptionBaseUrl || BuildConfig.PAYMENT_LINKS_AVAILABLE
copyUrlItem?.isVisible = !isSearchActive && (appBaseUrl != subscriptionBaseUrl || BuildConfig.PAYMENT_LINKS_AVAILABLE)
}
}
private fun showHideConnectionErrorMenuItem(details: Map<String, io.heckel.ntfy.db.ConnectionDetails>) {
if (!this::menu.isInitialized) {
return
}
private fun showHideConnectionErrorMenuItem() {
if (!this::menu.isInitialized) return
runOnUiThread {
val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error)
// Only show if there's an error for this subscription's base URL
val hasError = details[subscriptionBaseUrl]?.hasError() == true
connectionErrorItem?.isVisible = hasError
val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error)
val hasError = repository.getConnectionDetails()[subscriptionBaseUrl]?.hasError() == true
connectionErrorItem?.isVisible = !isSearchActive && hasError
}
}

View file

@ -12,19 +12,20 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class DetailViewModel(private val repository: Repository) : ViewModel() {
private val _searchQuery = MutableLiveData("")
val searchQuery: LiveData<String> = _searchQuery
private val searchQuery = MutableLiveData("")
fun setSearchQuery(query: String) {
_searchQuery.value = query
searchQuery.value = query
}
fun hasSearchQuery(): Boolean = !searchQuery.value.isNullOrBlank()
fun list(subscriptionId: Long): LiveData<List<Notification>> {
return repository.getNotificationsLiveData(subscriptionId)
}
fun listFiltered(subscriptionId: Long): LiveData<List<Notification>> {
return _searchQuery.switchMap { query ->
return searchQuery.switchMap { query ->
if (query.isNullOrBlank()) {
repository.getNotificationsLiveData(subscriptionId)
} else {

View file

@ -141,6 +141,7 @@
<string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string>
<string name="detail_how_to_link">Detailed instructions available on ntfy.sh, and in the docs.
</string>
<string name="detail_no_search_results">Your search returned no results</string>
<string name="detail_clear_dialog_message">Delete all of the notifications in this topic?
</string>
<string name="detail_clear_dialog_permanently_delete">Delete permanently</string>
@ -204,8 +205,7 @@
<string name="detail_menu_settings">Subscription settings</string>
<string name="detail_menu_unsubscribe">Unsubscribe</string>
<string name="detail_menu_search">Search notifications</string>
<string name="detail_search_hint">Search in notifications</string>
<string name="detail_no_search_results">No notifications match your search</string>
<string name="detail_menu_search_hint">Search in notifications</string>
<!-- Detail activity: Action mode -->
<string name="detail_action_mode_menu_delete">Delete</string>