mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #4162 from vitorpamplona/claude/push-notification-black-screens-heg0ze
Fix notification deep links and add inline reply feedback
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* The long-lived shortcut that turns a MessagingStyle notification into an actual
|
||||
* *conversation* as far as the system is concerned.
|
||||
*
|
||||
* MessagingStyle on its own is only half the contract. Since Android 11 the shade sorts a
|
||||
* notification into the Conversations section — and offers the per-conversation controls
|
||||
* that come with it (mark a person Priority, silence one room without silencing every DM) —
|
||||
* only when the notification names a **long-lived shortcut** through `setShortcutId`.
|
||||
* Without one it is ranked as an ordinary alerting notification, which is what every DM,
|
||||
* group message and Buzz room got before this existed, despite the KDoc claiming otherwise.
|
||||
*
|
||||
* The same shortcut is the prerequisite for two things worth having later: bubbles
|
||||
* (`setBubbleMetadata`) and Direct Share targets. Neither is wired yet.
|
||||
*
|
||||
* ### What gets published
|
||||
*
|
||||
* One shortcut per conversation, keyed by [NotificationUtils.Conversation.id] — which
|
||||
* includes the account, so the same counterparty under two logins does not collapse into one
|
||||
* launcher entry pointing at the wrong inbox. The intent is the same deep link the
|
||||
* notification taps through ([NotificationRoutes]), so the launcher entry and the tap agree.
|
||||
*
|
||||
* ### Why this is gated on a setting
|
||||
*
|
||||
* A dynamic shortcut is visible in the launcher's long-press menu: publishing one puts a
|
||||
* contact's name and avatar outside the app, where someone holding the phone can read it
|
||||
* without unlocking anything of ours. That is the disclosure `showMessagesInNotifications`
|
||||
* exists to control, so callers pass [NotificationUtils.Conversation] only when it is on.
|
||||
*/
|
||||
object ConversationShortcuts {
|
||||
private const val TAG = "ConversationShortcuts"
|
||||
|
||||
/**
|
||||
* Publishes (or refreshes) the conversation's shortcut and returns its id, or null when
|
||||
* the system would not take it.
|
||||
*
|
||||
* Null matters: a `setShortcutId` pointing at a shortcut that does not exist is worse
|
||||
* than none — the system logs it and still refuses the conversation treatment — so the
|
||||
* caller must only stamp the notification when this succeeded.
|
||||
*
|
||||
* [ShortcutManagerCompat.pushDynamicShortcut] already handles the two things that bite
|
||||
* here: it evicts the least-recently-used shortcut when the per-activity cap is reached,
|
||||
* and it is rate-limit aware. It can still throw on a malformed shortcut, so the whole
|
||||
* call is guarded — a conversation that cannot be published is a notification that
|
||||
* renders normally, never a crash on the notification path.
|
||||
*/
|
||||
fun push(
|
||||
context: Context,
|
||||
conversation: NotificationUtils.Conversation,
|
||||
uri: String,
|
||||
person: Person,
|
||||
icon: Bitmap?,
|
||||
): String? =
|
||||
try {
|
||||
val target =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
// A shortcut intent without an action is rejected outright.
|
||||
action = Intent.ACTION_VIEW
|
||||
data = uri.toUri()
|
||||
}
|
||||
|
||||
val shortcut =
|
||||
ShortcutInfoCompat
|
||||
.Builder(context, conversation.id)
|
||||
.setShortLabel(conversation.label)
|
||||
.setLongLabel(conversation.label)
|
||||
.setIntent(target)
|
||||
// Without this the system drops the shortcut as soon as it leaves the
|
||||
// dynamic list, and the conversation loses its history and its ranking.
|
||||
.setLongLived(true)
|
||||
.apply {
|
||||
// A group is not a person. Attaching the speaker would make the
|
||||
// launcher entry's face flip to whoever last spoke while the label
|
||||
// stayed the group, and would rank the chat as that one contact.
|
||||
if (!conversation.isGroup) setPerson(person)
|
||||
// Plain, not adaptive: `icon` is already circle-cropped, and the
|
||||
// adaptive mask would crop that circle a second time — zoomed, with
|
||||
// the edges clipped off.
|
||||
icon?.let { setIcon(IconCompat.createWithBitmap(it)) }
|
||||
}.build()
|
||||
|
||||
if (ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)) {
|
||||
conversation.id
|
||||
} else {
|
||||
Log.d(TAG) { "System declined the shortcut for ${conversation.label}" }
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Builder.build() throws on a malformed shortcut (an empty label, say) and it is
|
||||
// inside the guard for that reason: this runs from postConversation, whose caller
|
||||
// swallows exceptions, so a throw escaping here would silently drop the whole
|
||||
// notification rather than just its conversation status.
|
||||
Log.d(TAG) { "Could not publish a shortcut for ${conversation.label}: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every conversation this account published into the launcher.
|
||||
*
|
||||
* Logging out has to take these with it. They are the one part of an account that lives
|
||||
* outside the app's own storage — names and avatars of people it talked to, sitting in the
|
||||
* launcher's long-press menu — so an account that is gone from Amethyst but still lists its
|
||||
* contacts on the home screen would be a worse leak than not publishing them at all.
|
||||
*
|
||||
* Long-lived shortcuts are also removed from the system's cache, not just the dynamic list:
|
||||
* the cache is what keeps a conversation alive after it falls out of the top slots, so
|
||||
* dropping only the dynamic copy would leave it recoverable.
|
||||
*/
|
||||
fun removeForAccount(
|
||||
context: Context,
|
||||
accountNpub: String,
|
||||
) {
|
||||
val scope = ":$accountNpub:"
|
||||
try {
|
||||
// Every list the system keeps, not just the dynamic one. `pushDynamicShortcut`
|
||||
// evicts the least-recently-used shortcut from the dynamic list when the cap is
|
||||
// reached, and a long-lived shortcut that has been used in a notification is kept
|
||||
// in the system *cache* rather than deleted — so the conversations most likely to
|
||||
// have fallen out of the dynamic list are exactly the ones a dynamic-only sweep
|
||||
// would leave behind, which is the leak this is here to close.
|
||||
val mine =
|
||||
ShortcutManagerCompat
|
||||
.getShortcuts(
|
||||
context,
|
||||
ShortcutManagerCompat.FLAG_MATCH_DYNAMIC or
|
||||
ShortcutManagerCompat.FLAG_MATCH_CACHED or
|
||||
ShortcutManagerCompat.FLAG_MATCH_PINNED,
|
||||
).map { it.id }
|
||||
.filter { it.contains(scope) }
|
||||
|
||||
if (mine.isEmpty()) return
|
||||
|
||||
// A shortcut the user pinned to their home screen cannot be deleted by us, only
|
||||
// disabled — so disable first, then remove. Without this a pinned conversation
|
||||
// would survive the logout as a live tile.
|
||||
ShortcutManagerCompat.disableShortcuts(
|
||||
context,
|
||||
mine,
|
||||
stringRes(context, R.string.app_notification_shortcut_account_removed),
|
||||
)
|
||||
ShortcutManagerCompat.removeLongLivedShortcuts(context, mine)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG) { "Could not clear shortcuts for $accountNpub: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* What became of a reply the user typed into the shade.
|
||||
*
|
||||
* The three outcomes are not two: a send that has neither returned nor thrown is its own
|
||||
* thing, and saying so is the only honest option — see [Unconfirmed].
|
||||
*/
|
||||
sealed interface ReplyState {
|
||||
/** On its way. [text] joins the thread so the user can see what they sent. */
|
||||
data class Sending(
|
||||
val text: String,
|
||||
) : ReplyState
|
||||
|
||||
/** It went out — drop the marker and leave the thread as it now reads. */
|
||||
data object Sent : ReplyState
|
||||
|
||||
/**
|
||||
* Long enough that "Sending…" has stopped being true, without an answer either way.
|
||||
*
|
||||
* A broadcast receiver is killed around ten seconds in, and a NIP-17 send can exceed that
|
||||
* on its own: an Amber round trip, a relay publish, proof-of-work mining. Whatever the
|
||||
* shade is showing when the process dies is what it shows until the conversation is read,
|
||||
* so it must not be a claim we cannot stand behind.
|
||||
*
|
||||
* Deliberately offers no Retry. The message may well have gone out — re-sending would
|
||||
* deliver it twice, which is worse than leaving the user to open the app and look.
|
||||
*/
|
||||
data class Unconfirmed(
|
||||
val text: String,
|
||||
) : ReplyState
|
||||
|
||||
/**
|
||||
* It threw. [retry] re-sends the same text on one tap, carrying the text itself because a
|
||||
* RemoteInput cannot be pre-filled and re-typing it is the thing this prevents.
|
||||
*/
|
||||
data class Failed(
|
||||
val text: String,
|
||||
val retry: PendingIntent,
|
||||
) : ReplyState
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders the live notification for [notId] to say what happened to an inline reply.
|
||||
*
|
||||
* Until this existed a reply typed in the shade was posted into silence: the send either
|
||||
* worked, and the notification vanished with no sign the message had gone, or it threw, and
|
||||
* nothing at all happened — same notification, text gone, user believing it sent. A denied
|
||||
* signature or a dead socket was indistinguishable from delivery.
|
||||
*
|
||||
* The live notification is the only place the reply can be shown, so it is rebuilt from
|
||||
* itself rather than from scratch: [NotificationCompat.Builder] can recover a builder from a
|
||||
* posted [Notification], and MessagingStyle can be extracted and extended. Callers that are
|
||||
* not conversations (BigText notifications carrying an inline reply) have no thread to append
|
||||
* to and get `setRemoteInputHistory`, which is the same idea in the shape that style supports.
|
||||
*
|
||||
* Returns false when nothing is posted under [notId] any more — the user swiped it away while
|
||||
* the reply was in flight. Nothing is re-posted in that case: they are done with it.
|
||||
*/
|
||||
fun NotificationManager.renderReplyState(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
state: ReplyState,
|
||||
): Boolean =
|
||||
try {
|
||||
renderReplyStateOrThrow(applicationContext, notId, state)
|
||||
} catch (e: Exception) {
|
||||
// Feedback must never break the thing it reports on. Reading the live notification
|
||||
// and posting it back both go through the system, and both can throw (a revoked
|
||||
// POST_NOTIFICATIONS between check and notify, say) — and this runs either side of
|
||||
// the send, where an escape would cost the user the reply itself.
|
||||
Log.w(TAG, "Could not render the reply state", e)
|
||||
false
|
||||
}
|
||||
|
||||
private const val TAG = "InlineReplyFeedback"
|
||||
|
||||
private fun NotificationManager.renderReplyStateOrThrow(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
state: ReplyState,
|
||||
): Boolean {
|
||||
val existing = activeNotifications.firstOrNull { it.id == notId }?.notification ?: return false
|
||||
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, existing)
|
||||
// The thread already alerted when the message arrived; an update about the user's
|
||||
// own reply must not buzz again.
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
val style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(existing)
|
||||
|
||||
when (state) {
|
||||
is ReplyState.Sending -> {
|
||||
// `null` attributes the message to the MessagingStyle's own user.
|
||||
style?.addMessage(state.text, System.currentTimeMillis(), null as Person?)
|
||||
?: builder.setRemoteInputHistory(arrayOf(state.text))
|
||||
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_sending))
|
||||
}
|
||||
|
||||
ReplyState.Sent -> {
|
||||
// Sending already put the text in the thread — appending here would double it.
|
||||
builder.setSubText(null)
|
||||
}
|
||||
|
||||
is ReplyState.Unconfirmed -> {
|
||||
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
|
||||
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_unconfirmed))
|
||||
}
|
||||
|
||||
is ReplyState.Failed -> {
|
||||
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
|
||||
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_failed))
|
||||
|
||||
val retryLabel = stringRes(applicationContext, R.string.app_notification_reply_retry)
|
||||
|
||||
// Rebuilt from the posted notification, so its actions come with it — and a bare
|
||||
// clearActions() would take Reply and Mark Read with them, leaving a failed reply
|
||||
// with no way to write a different one. Keep them, drop any Retry left by an
|
||||
// earlier attempt so repeated failures don't stack, then add this one.
|
||||
val kept =
|
||||
(0 until NotificationCompat.getActionCount(existing))
|
||||
.mapNotNull { NotificationCompat.getAction(existing, it) }
|
||||
.filter { it.title?.toString() != retryLabel }
|
||||
|
||||
builder.clearActions()
|
||||
kept.forEach { builder.addAction(it) }
|
||||
builder.addAction(
|
||||
NotificationCompat.Action
|
||||
.Builder(R.drawable.ic_action_reply, retryLabel, state.retry)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
style?.let { builder.setStyle(it) }
|
||||
|
||||
notify(notId, builder.build())
|
||||
return true
|
||||
}
|
||||
+114
-22
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -46,11 +47,22 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
/**
|
||||
* How long a send may run before the notification stops claiming it is sending. Set
|
||||
* under the ten seconds a foreground broadcast gets, so the honest state is rendered
|
||||
* while this process is still alive to render it.
|
||||
*/
|
||||
private const val UNCONFIRMED_AFTER_MS = 8_000L
|
||||
}
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
@@ -88,12 +100,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
NotificationUtils.REPLY_ACTION -> {
|
||||
val replyText =
|
||||
RemoteInput
|
||||
.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
|
||||
val replyText = replyTextFrom(intent)
|
||||
if (replyText.isNullOrBlank()) return
|
||||
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
@@ -102,35 +109,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
if (members.isEmpty()) return
|
||||
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
|
||||
sendReply(accountNpub, members, replyText)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationUtils.PUBLIC_REPLY_ACTION -> {
|
||||
val replyText =
|
||||
RemoteInput
|
||||
.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
|
||||
val replyText = replyTextFrom(intent)
|
||||
if (replyText.isNullOrBlank()) return
|
||||
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
|
||||
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
|
||||
sendPublicReply(accountNpub, targetEventId, replyText)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationUtils.MARMOT_REPLY_ACTION -> {
|
||||
val replyText =
|
||||
RemoteInput
|
||||
.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
|
||||
val replyText = replyTextFrom(intent)
|
||||
if (replyText.isNullOrBlank()) return
|
||||
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
@@ -138,22 +135,51 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
|
||||
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
|
||||
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
|
||||
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of an inline reply: typed into the shade, or carried by a Retry re-sending one
|
||||
* that failed. A RemoteInput cannot be pre-filled, so a retry has to bring its own copy.
|
||||
*/
|
||||
private fun replyTextFrom(intent: Intent): String? =
|
||||
RemoteInput
|
||||
.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
?: intent.getStringExtra(NotificationUtils.KEY_REPLY_TEXT)
|
||||
|
||||
/**
|
||||
* Sends [block] and keeps the notification honest about how it went.
|
||||
*
|
||||
* The notification stays up rather than being cancelled on success. That is what every
|
||||
* other messenger does — the reply appears in the thread you replied to — and it is what
|
||||
* makes a failure visible at all: there is something left on screen to put the error on.
|
||||
* It clears the usual way, when the conversation is read in the app.
|
||||
*
|
||||
* The dismissal guard is recorded on **both** outcomes. On success it stops the enrichment
|
||||
* window resurrecting the pre-reply version seconds later; on failure it stops that same
|
||||
* re-render overwriting the error and the Retry that carries the user's text. A nicer
|
||||
* avatar is not worth losing an unsent message to.
|
||||
*/
|
||||
private fun runOnRelay(
|
||||
context: Context,
|
||||
notificationManager: NotificationManager,
|
||||
notificationId: Int,
|
||||
eventId: String?,
|
||||
replyText: String,
|
||||
source: Intent,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
val pendingResult = goAsync()
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
val appContext = context.applicationContext
|
||||
|
||||
scope.launch {
|
||||
val collectionJob =
|
||||
scope.launch {
|
||||
@@ -161,13 +187,48 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Stops "Sending…" becoming a lie the shade keeps telling. A broadcast receiver is
|
||||
// killed around ten seconds in, and a NIP-17 send can outlive that on its own — an
|
||||
// Amber round trip, a relay publish, proof-of-work mining — so whatever is on
|
||||
// screen at that moment is what the user is left with.
|
||||
//
|
||||
// A watchdog, deliberately, not a `withTimeout`: timing the send out would *cancel*
|
||||
// it, and a publish cancelled halfway is a worse outcome than a slow one. This only
|
||||
// changes what the notification says. If the send does finish afterwards, the Sent
|
||||
// or Failed render below overwrites it.
|
||||
val watchdog =
|
||||
scope.launch {
|
||||
delay(UNCONFIRMED_AFTER_MS)
|
||||
notificationManager.renderReplyState(
|
||||
appContext,
|
||||
notificationId,
|
||||
ReplyState.Unconfirmed(replyText),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Inside the try: everything from here on must reach the `finally`, which is
|
||||
// what calls pendingResult.finish() and releases the broadcast.
|
||||
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sending(replyText))
|
||||
block()
|
||||
// Joined, not just cancelled: if the watchdog is already inside its render, the
|
||||
// two notify() calls would land in an undefined order and the shade could keep
|
||||
// "Still sending…" over a message that went out.
|
||||
watchdog.cancelAndJoin()
|
||||
eventId?.let { NotificationUtils.markDismissed(it) }
|
||||
notificationManager.cancelAndPrune(notificationId)
|
||||
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sent)
|
||||
} catch (e: Exception) {
|
||||
// Rethrown first: joining is a suspend call, which an already-cancelled
|
||||
// coroutine cannot make. `scope.cancel()` below takes the watchdog with it.
|
||||
if (e is CancellationException) throw e
|
||||
watchdog.cancelAndJoin()
|
||||
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
|
||||
eventId?.let { NotificationUtils.markDismissed(it) }
|
||||
notificationManager.renderReplyState(
|
||||
appContext,
|
||||
notificationId,
|
||||
ReplyState.Failed(replyText, retryIntent(appContext, notificationId, replyText, source)),
|
||||
)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
collectionJob.cancel()
|
||||
@@ -176,6 +237,37 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-tap re-send of exactly what the user typed.
|
||||
*
|
||||
* A copy of [source] — which already carries the action and the account, room, group or
|
||||
* target this reply was addressed to — plus the text. Keeping the original action is what
|
||||
* makes this free: [onReceive] routes it back to the branch it came from with no alias to
|
||||
* resolve, the dismissal guard already treats it as the reply action it is, and
|
||||
* [replyTextFrom] already prefers a RemoteInput result and falls back to this extra.
|
||||
*/
|
||||
private fun retryIntent(
|
||||
applicationContext: Context,
|
||||
notificationId: Int,
|
||||
replyText: String,
|
||||
source: Intent,
|
||||
): PendingIntent {
|
||||
val intent =
|
||||
Intent(source).apply {
|
||||
setClass(applicationContext, NotificationReplyReceiver::class.java)
|
||||
putExtra(NotificationUtils.KEY_REPLY_TEXT, replyText)
|
||||
}
|
||||
|
||||
return PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
// The other three request codes for this notification are notId, +1 (mark read)
|
||||
// and +2 (dismiss).
|
||||
notificationId + 3,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun sendReply(
|
||||
accountNpub: String,
|
||||
chatroomMembers: List<String>,
|
||||
|
||||
+87
-3
@@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
|
||||
/** Deep-link URIs consumed by `MainActivity.uriToRoute` when a notification is tapped. */
|
||||
@@ -38,11 +39,88 @@ object NotificationRoutes {
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
|
||||
/** Opens the note directly (used for replies, mentions, DMs, media, git). */
|
||||
/**
|
||||
* Opens the note directly (used for replies, mentions, media, git).
|
||||
*
|
||||
* A note delivered inside an envelope (a NIP-17 private note or reply) cannot be
|
||||
* addressed this way — see [privateNoteUri].
|
||||
*/
|
||||
fun noteUri(
|
||||
note: Note,
|
||||
accountNpub: String,
|
||||
): String = note.toNEvent() + ACCOUNT + accountNpub
|
||||
): String =
|
||||
if (note.rumorHost != null) {
|
||||
privateNoteUri(note.idHex, accountNpub)
|
||||
} else {
|
||||
note.toNEvent() + ACCOUNT + accountNpub
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a NIP-17 private note or reply by the rumor's own id.
|
||||
*
|
||||
* [Note.toNEvent] cannot address one: for a rumor it encodes the kind-1059 gift wrap
|
||||
* that delivered it, because the rumor id must never appear in anything that leaves
|
||||
* for a relay (`RumorHostCitationTest` pins that, and [Note.isPrivateRumor] guards the
|
||||
* publish paths). Two things then go wrong on a tap. The wrap is unfetchable — the
|
||||
* general event finder asks the account's read relays for an id only the DM inbox
|
||||
* relays ever held. And the wrap note emits exactly once, from `consumeRegularEvent`,
|
||||
* *before* it is decrypted, so `routeFor` sees a null `innerEventId` and the redirect
|
||||
* never fires again (the later `event = copyNoContent()` is a plain assignment).
|
||||
*
|
||||
* The rumor id has neither problem: its note emits when the rumor is consumed, and the
|
||||
* always-on one-week gift-wrap tail re-delivers and re-unwraps the envelope on every
|
||||
* cold start, so a push-recent message always comes back on its own. This URI is read
|
||||
* only by `MainActivity.uriToRoute`, inside our own process, and the route it produces
|
||||
* never puts the id in a REQ — see `Route.EventRedirect.isPrivate`.
|
||||
*/
|
||||
fun privateNoteUri(
|
||||
rumorId: String,
|
||||
accountNpub: String,
|
||||
): String = "privatenote?id=$rumorId&account=$accountNpub"
|
||||
|
||||
/**
|
||||
* Opens a NIP-17 / NIP-04 private chatroom (DM notifications).
|
||||
*
|
||||
* A DM must NOT deep-link through its own note: [Note.toNEvent] cites the kind-1059
|
||||
* gift wrap that delivered the rumor (it has to — the rumor id is the private event's
|
||||
* identity and resolves to nothing on a public relay, see `RumorHostCitationTest`).
|
||||
* Resolving that nevent back to a room needs the wrap *and* seal notes to still be in
|
||||
* the in-memory `LocalCache`. A push usually wakes a cold process, so by the time the
|
||||
* user taps, the cache is empty — and no relay will ever serve that wrap again, so the
|
||||
* tap landed on a `Route.EventRedirect` that could never resolve.
|
||||
*
|
||||
* The room key is derived locally and needs no event at all, so this survives the
|
||||
* process dying. Same idea as [relayGroupUri], which routes a Buzz DM through the
|
||||
* channel's naddr rather than the message.
|
||||
*/
|
||||
fun chatroomUri(
|
||||
room: ChatroomKey,
|
||||
accountNpub: String,
|
||||
): String = "chatroom?id=${room.users.joinToString(",")}&account=$accountNpub"
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Conversation shortcut ids
|
||||
//
|
||||
// Stable for the life of the chat and scoped to the account, so the same counterparty
|
||||
// under two logins does not collapse into one launcher entry pointing at the wrong
|
||||
// inbox. Members are sorted because a room key is a set — ordering must not create a
|
||||
// second shortcut for a chat that already has one.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fun chatroomShortcutId(
|
||||
room: ChatroomKey,
|
||||
accountNpub: String,
|
||||
): String = "dm:$accountNpub:" + room.users.sorted().joinToString(",")
|
||||
|
||||
fun marmotShortcutId(
|
||||
nostrGroupId: String,
|
||||
accountNpub: String,
|
||||
): String = "marmot:$accountNpub:$nostrGroupId"
|
||||
|
||||
fun relayGroupShortcutId(
|
||||
channelNAddr: String,
|
||||
accountNpub: String,
|
||||
): String = "relaygroup:$accountNpub:$channelNAddr"
|
||||
|
||||
/** Opens the Notifications tab, scrolled to [scrollToId] (used for zaps, reactions, chess). */
|
||||
fun notificationsUri(
|
||||
@@ -50,7 +128,13 @@ object NotificationRoutes {
|
||||
scrollToId: String,
|
||||
): String = "notifications$ACCOUNT$accountNpub$SCROLL_TO$scrollToId"
|
||||
|
||||
/** Opens a Marmot group chatroom (welcome + group message). */
|
||||
/**
|
||||
* Opens a Marmot group chatroom (welcome + group message).
|
||||
*
|
||||
* Note the query here rides on an *opaque* URI (a scheme with no `//`), so its
|
||||
* `?account=` can only be read by [String.findQueryParameterValue] — see the note
|
||||
* there; `java.net.URI.rawQuery` is null for this shape.
|
||||
*/
|
||||
fun marmotUri(
|
||||
nostrGroupId: String,
|
||||
accountNpub: String,
|
||||
|
||||
+46
@@ -37,6 +37,7 @@ import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.LocusIdCompat
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
@@ -80,6 +81,7 @@ object NotificationUtils {
|
||||
* [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to.
|
||||
*/
|
||||
const val KEY_EVENT_ID = "key_event_id"
|
||||
|
||||
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
||||
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
||||
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
|
||||
@@ -166,6 +168,29 @@ object NotificationUtils {
|
||||
) : ReplyAction
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of the chat a conversation notification belongs to, so the system can treat it
|
||||
* as one — see [ConversationShortcuts] for what that buys and why it needs a shortcut.
|
||||
*
|
||||
* [id] must be stable for the life of the conversation and unique across accounts; [label]
|
||||
* is what the conversation is called, which is not always the sender (a group message is
|
||||
* from a person but belongs to the group).
|
||||
*
|
||||
* Callers pass this only when the account allows message content in notifications: the
|
||||
* shortcut it publishes is readable from the launcher.
|
||||
*/
|
||||
data class Conversation(
|
||||
val id: String,
|
||||
val label: String,
|
||||
/**
|
||||
* The chat's own picture, when it has one. Null falls back to the message sender's
|
||||
* avatar, which is the right face for a one-to-one DM and the wrong one for a group —
|
||||
* hence [isGroup], which suppresses that fallback along with the sender `Person`.
|
||||
*/
|
||||
val iconUrl: String? = null,
|
||||
val isGroup: Boolean = false,
|
||||
)
|
||||
|
||||
/** A prior message rendered above the main one in a MessagingStyle notification (thread context). */
|
||||
data class ParentMessage(
|
||||
val senderName: String,
|
||||
@@ -281,6 +306,7 @@ object NotificationUtils {
|
||||
replyAction: ReplyAction? = null,
|
||||
publicInlineReply: InlineReplyTarget? = null,
|
||||
addMarkRead: Boolean = true,
|
||||
conversation: Conversation? = null,
|
||||
groupKey: String = category.group,
|
||||
summaryId: Int = category.summaryId,
|
||||
) {
|
||||
@@ -330,6 +356,17 @@ object NotificationUtils {
|
||||
|
||||
val contentPendingIntent = contentIntent(applicationContext, notId, uri)
|
||||
|
||||
// Published before the notification that names it: a shortcutId the system cannot
|
||||
// resolve is worse than none, so the id is only stamped below when this succeeded.
|
||||
val shortcutId =
|
||||
conversation?.let {
|
||||
val conversationIcon =
|
||||
it.iconUrl?.let { url -> loadBitmap(url, applicationContext)?.let { bmp -> circleCrop(bmp) } }
|
||||
?: avatar.takeUnless { _ -> it.isGroup }
|
||||
|
||||
ConversationShortcuts.push(applicationContext, it, uri, sender, conversationIcon)
|
||||
}
|
||||
|
||||
val builderPublic =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, channelId)
|
||||
@@ -360,6 +397,15 @@ object NotificationUtils {
|
||||
.setOnlyAlertOnce(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
// The three halves of the conversation contract: the shortcut the shade resolves, the
|
||||
// locus that ties this notification to it, and the people it is with. All three have to
|
||||
// be present or the notification is ranked as an ordinary alert.
|
||||
if (shortcutId != null) {
|
||||
builder.setShortcutId(shortcutId)
|
||||
builder.setLocusId(LocusIdCompat(shortcutId))
|
||||
}
|
||||
builder.addPerson(sender)
|
||||
|
||||
when (replyAction) {
|
||||
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction))
|
||||
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction))
|
||||
|
||||
+16
-1
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -82,10 +83,23 @@ object BuzzDmNotification {
|
||||
val accountNpub = NotificationRoutes.accountNpub(account)
|
||||
// The channel's kind-39000 naddr routes straight to the chatroom via the
|
||||
// existing naddr → Route.RelayGroup path (no message load needed on tap).
|
||||
val channelNAddr = channel.toNAddr()
|
||||
val uri =
|
||||
channel.toNAddr()?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
|
||||
channelNAddr?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
|
||||
?: NotificationRoutes.noteUri(note, accountNpub)
|
||||
|
||||
// Only the naddr identifies the room durably; without it there is no conversation to
|
||||
// pin a shortcut to. The message-content gate is the early return at the top.
|
||||
val conversation =
|
||||
channelNAddr?.let {
|
||||
Conversation(
|
||||
id = NotificationRoutes.relayGroupShortcutId(it, accountNpub),
|
||||
label = channel.toBestDisplayName(),
|
||||
iconUrl = channel.profilePicture(),
|
||||
isGroup = true,
|
||||
)
|
||||
}
|
||||
|
||||
val nm = context.notificationManager()
|
||||
|
||||
NotificationEnricher.enrichAndPost(
|
||||
@@ -107,6 +121,7 @@ object BuzzDmNotification {
|
||||
applicationContext = context,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
replyAction = null,
|
||||
conversation = conversation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -21,12 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
@@ -96,7 +98,23 @@ object DirectMessageNotification {
|
||||
}
|
||||
|
||||
val accountNpub = NotificationRoutes.accountNpub(account)
|
||||
val uri = NotificationRoutes.noteUri(chatNote, accountNpub)
|
||||
// The room, not the message: a rumor's nevent cites the gift wrap that delivered it,
|
||||
// which is gone from LocalCache as soon as the process dies and is unfetchable from
|
||||
// any relay. See [NotificationRoutes.chatroomUri].
|
||||
val uri = NotificationRoutes.chatroomUri(chatRoom, accountNpub)
|
||||
|
||||
// Names the chat for the shade's Conversations section. Withheld when the account has
|
||||
// turned message content off, because publishing it puts the counterparty's name and
|
||||
// avatar in the launcher — see [ConversationShortcuts].
|
||||
val conversation =
|
||||
if (account.settings.showMessagesInNotifications.value) {
|
||||
Conversation(
|
||||
id = NotificationRoutes.chatroomShortcutId(chatRoom, accountNpub),
|
||||
label = roomLabel(chatRoom, author),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val replyAction =
|
||||
if (decrypt) {
|
||||
null // NIP-04 is read-only in the tray
|
||||
@@ -125,7 +143,22 @@ object DirectMessageNotification {
|
||||
applicationContext = context,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
replyAction = replyAction,
|
||||
conversation = conversation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call the chat. A one-to-one room is the other person, which is also the sender;
|
||||
* a group room is everyone in it, so the sender's name alone would mislabel it.
|
||||
*/
|
||||
private fun roomLabel(
|
||||
chatRoom: ChatroomKey,
|
||||
author: User,
|
||||
): String =
|
||||
if (chatRoom.users.size <= 1) {
|
||||
author.toBestDisplayName()
|
||||
} else {
|
||||
chatRoom.users.joinToString(", ") { LocalCache.getOrCreateUser(it).toBestDisplayName() }
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
@@ -61,6 +62,20 @@ object GroupMessageNotification {
|
||||
|
||||
val accountNpub = NotificationRoutes.accountNpub(account)
|
||||
val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub)
|
||||
// Withheld when the account has message content turned off — the shortcut it publishes
|
||||
// names the group in the launcher. See [ConversationShortcuts].
|
||||
val conversation =
|
||||
if (account.settings.showMessagesInNotifications.value) {
|
||||
// No iconUrl: a Marmot group's avatar is an encrypted Blossom blob, not a
|
||||
// URL an image loader can take.
|
||||
Conversation(
|
||||
id = NotificationRoutes.marmotShortcutId(nostrGroupId, accountNpub),
|
||||
label = groupName,
|
||||
isGroup = true,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val nm = context.notificationManager()
|
||||
|
||||
NotificationEnricher.enrichAndPost(
|
||||
@@ -81,6 +96,7 @@ object GroupMessageNotification {
|
||||
uri = uri,
|
||||
applicationContext = context,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
conversation = conversation,
|
||||
replyAction =
|
||||
ReplyAction.Marmot(
|
||||
accountNpub = accountNpub,
|
||||
|
||||
+15
@@ -59,6 +59,21 @@ fun observeNote(
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [observeNote] without the relay half: watches LocalCache and asks no relay for the note.
|
||||
*
|
||||
* For a NIP-17 rumor, which has no fetchable id — putting one in a REQ would tell relays the
|
||||
* private event's identity, the leak [com.vitorpamplona.amethyst.commons.model.Note.isPrivateRumor]
|
||||
* guards everywhere else. Nothing is lost by not asking: a rumor only ever reaches the cache by
|
||||
* unwrapping the envelope that carried it, and the always-on gift-wrap tail re-fetches a week of
|
||||
* those on every cold start, so this flow fires on its own once the envelope lands.
|
||||
*/
|
||||
@Composable
|
||||
fun observeNoteLocally(note: Note): State<NoteState> {
|
||||
val flow = remember(note) { note.flow().metadata.stateFlow }
|
||||
return flow.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
inline fun <reified T : Event> observeNoteEvent(
|
||||
|
||||
@@ -34,16 +34,20 @@ import com.vitorpamplona.amethyst.debugState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRelayService
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
|
||||
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
|
||||
@@ -172,6 +176,56 @@ fun fragmentHashtagOrNull(uri: String): String? {
|
||||
|
||||
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
|
||||
|
||||
/**
|
||||
* A private chatroom, addressed by its participants rather than by a message.
|
||||
* Posted by DM notifications — see [NotificationRoutes.chatroomUri] for why a DM
|
||||
* cannot deep-link through its own note.
|
||||
*/
|
||||
fun isChatroomRoute(uri: String) = uri.startsWith("chatroom?id=") || uri.startsWith("nostr:chatroom?id=")
|
||||
|
||||
/**
|
||||
* Note what this deliberately does *not* do: register the room on the account.
|
||||
*
|
||||
* `routeToMessage` would, and that is right for the in-app callers, but wrong here twice over.
|
||||
* `uriToRoute` runs against whichever account is current, *before* `?account=` is read and the
|
||||
* switch happens (`AppNavigation.NavigateIfIntentRequested`), so a DM notification for account B
|
||||
* tapped while A is on screen would insert an empty conversation into **A**'s message list. And
|
||||
* `nostr:` is an exported, browsable scheme, so any web page could post
|
||||
* `nostr:chatroom?id=<hex>&account=…` and inject a room of its choosing.
|
||||
*
|
||||
* Nothing needs it: `ChatroomFeedFilter.chatroom()` calls `getOrCreatePrivateChatroom` when the
|
||||
* screen actually opens, by which point the switch has happened and the room lands on the right
|
||||
* account.
|
||||
*
|
||||
* The ids are still checked here — a room key is a set of pubkeys, so anything that isn't one is
|
||||
* not a room, and a bad deep link should resolve to nothing rather than to an unopenable screen.
|
||||
*/
|
||||
fun chatroomRoute(uri: String): Route? {
|
||||
val users =
|
||||
uri
|
||||
.findQueryParameterValue("id")
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.takeIf { it.isNotEmpty() && it.all(::isPubKeyHex) }
|
||||
?.toSet() ?: return null
|
||||
|
||||
return Route.Room(ChatroomKey(users))
|
||||
}
|
||||
|
||||
/** A bare 32-byte lowercase-hex pubkey, the only thing a chatroom key is made of. */
|
||||
private fun isPubKeyHex(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
|
||||
|
||||
/**
|
||||
* A NIP-17 private note or reply, addressed by the rumor's own id because its `nevent`
|
||||
* names the undeliverable gift wrap instead — see [NotificationRoutes.privateNoteUri].
|
||||
*/
|
||||
fun isPrivateNoteRoute(uri: String) = uri.startsWith("privatenote?id=") || uri.startsWith("nostr:privatenote?id=")
|
||||
|
||||
fun privateNoteRoute(uri: String): Route? {
|
||||
val id = uri.findQueryParameterValue("id") ?: return null
|
||||
return Route.EventRedirect(id, isPrivate = true)
|
||||
}
|
||||
|
||||
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
|
||||
|
||||
/**
|
||||
@@ -232,7 +286,7 @@ fun uriToRoute(
|
||||
account: Account,
|
||||
): Route? {
|
||||
if (isNotificationRoute(uri)) {
|
||||
val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull()
|
||||
val scrollTo = uri.findQueryParameterValue("scrollTo")
|
||||
return Route.Notification(scrollToEventId = scrollTo)
|
||||
}
|
||||
if (isActiveSubscriptionsRoute(uri)) {
|
||||
@@ -250,6 +304,12 @@ fun uriToRoute(
|
||||
if (isUrlRoute(uri)) {
|
||||
return urlRoute(uri)
|
||||
}
|
||||
if (isChatroomRoute(uri)) {
|
||||
return chatroomRoute(uri)
|
||||
}
|
||||
if (isPrivateNoteRoute(uri)) {
|
||||
return privateNoteRoute(uri)
|
||||
}
|
||||
if (isConnectedAppRoute(uri)) {
|
||||
return connectedAppRoute(uri)
|
||||
}
|
||||
@@ -284,10 +344,15 @@ fun uriToRoute(
|
||||
}
|
||||
|
||||
is NEvent -> {
|
||||
routeFor(
|
||||
note = LocalCache.getOrCreateNote(nip19.hex),
|
||||
loggedIn = account,
|
||||
) ?: Route.EventRedirect(nip19.hex)
|
||||
val note = LocalCache.getOrCreateNote(nip19.hex)
|
||||
// Only fall back to the pointer's own kind while the body is missing: once the
|
||||
// event is cached it may belong somewhere the kind alone can't name (a channel,
|
||||
// a chatroom), and routeFor knows that.
|
||||
if (note.event == null) {
|
||||
routeForPointer(nip19.kind, nip19.hex) ?: Route.EventRedirect(nip19.hex)
|
||||
} else {
|
||||
routeFor(note = note, loggedIn = account) ?: Route.EventRedirect(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
is NAddress -> {
|
||||
|
||||
@@ -886,7 +886,7 @@ fun BuildNavigation(
|
||||
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
|
||||
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
|
||||
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, it.isPrivate, accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.GeoPost> {
|
||||
GeoHashPostScreen(
|
||||
@@ -1167,7 +1167,7 @@ private fun NavigateIfIntentRequested(
|
||||
LaunchedEffect(intentNextPage) {
|
||||
if (actionableNextPage != null) {
|
||||
actionableNextPage?.let { nextRoute ->
|
||||
val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
|
||||
val npub = intentNextPage.findQueryParameterValue("account")
|
||||
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
|
||||
accountSessionManager.checkAndSwitchUserSync(npub) { account ->
|
||||
uriToRoute(intentNextPage, account)
|
||||
@@ -1254,7 +1254,7 @@ private fun NavigateIfIntentRequested(
|
||||
|
||||
if (newPage != null) {
|
||||
scope.launch {
|
||||
val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
|
||||
val npub = uri.findQueryParameterValue("account")
|
||||
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
|
||||
accountSessionManager.checkAndSwitchUserSync(npub) { newAccount ->
|
||||
uriToRoute(uri, newAccount)
|
||||
@@ -1304,3 +1304,34 @@ fun URI.findParameterValue(parameterName: String): String? =
|
||||
Pair(name, value)
|
||||
}?.firstOrNull { it.first == parameterName }
|
||||
?.second
|
||||
|
||||
/**
|
||||
* The value of [parameterName] in this URI's query string, without going through
|
||||
* [java.net.URI].
|
||||
*
|
||||
* Needed because `java.net.URI` only exposes `rawQuery` for *hierarchical* URIs. A URI
|
||||
* with a scheme and no `//` is **opaque** — everything after the colon is one
|
||||
* scheme-specific part — so `URI("marmot:<hex>?account=npub1…").rawQuery` is null. That
|
||||
* is the shape [com.vitorpamplona.amethyst.service.notifications.NotificationRoutes.marmotUri]
|
||||
* produces, so every Marmot group notification silently lost its `?account=` and opened
|
||||
* the group under whichever account happened to be current instead of switching first.
|
||||
*
|
||||
* Splitting on the first `?` gets the same answer for both shapes, and returns null for a
|
||||
* bare `nevent1…` with no query at all.
|
||||
*
|
||||
* [com.vitorpamplona.quartz.utils.UriParser] reads an opaque query correctly too, and is the
|
||||
* right tool when a URI is already known to be well-formed. It is not this one: it builds a
|
||||
* [java.net.URI], which *throws* on anything that is not a legal URI. What arrives here comes
|
||||
* from an exported, browsable scheme, so it can be any string at all, and every caller on the
|
||||
* deep-link path treats an unreadable uri as "no route" rather than as a crash.
|
||||
*/
|
||||
fun String.findQueryParameterValue(parameterName: String): String? {
|
||||
val query = substringAfter('?', "")
|
||||
if (query.isEmpty()) return null
|
||||
|
||||
return query
|
||||
.split('&')
|
||||
.firstOrNull { it.substringBefore('=') == parameterName }
|
||||
?.substringAfter('=', "")
|
||||
?.ifEmpty { null }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
@@ -60,9 +64,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
@@ -98,6 +106,62 @@ fun minichatRouteFor(note: Note): Route? {
|
||||
|
||||
private fun Note.isInChatGatherer(): Boolean = inGatherers?.any { it is ConcordChannel || it is RelayGroupChannel || it is PublicChatChannel } == true
|
||||
|
||||
/**
|
||||
* Kinds whose destination is `Route.Note(id)` — the generic thread view — no matter what the
|
||||
* event body turns out to say. Mirrors the `else ->` branch of [routeForInner]: every kind here
|
||||
* is a plain note that carries nothing (no channel id, no `a` tag, no chatroom key) that could
|
||||
* send it somewhere more specific.
|
||||
*
|
||||
* **An addressable kind can never be listed here**, however plain it looks. [routeForInner]
|
||||
* sends those to `Route.Note(addressTag())` through its `is AddressableEvent` branch, and the
|
||||
* difference is not cosmetic: relays do not serve a replaceable by the id of one of its
|
||||
* versions, so routing by id is a dead end. This bit the NIP-71 video kinds — 21/22 are regular
|
||||
* but 34235/34236 extend `AddressableVideoEvent` — which is why the pair is split below and why
|
||||
* `RouteForPointerTest` walks this list against [routeForInner] itself rather than trusting it.
|
||||
*
|
||||
* `internal` so that test can iterate the real list: a kind added here is covered by
|
||||
* construction, not by someone remembering to add a case.
|
||||
*/
|
||||
internal val THREAD_VIEW_KINDS =
|
||||
intArrayOf(
|
||||
TextNoteEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
PictureEvent.KIND,
|
||||
// NIP-71 regular video only (21/22). The addressable pair, 34235/34236, is cited by
|
||||
// `naddr` and routed by address — see the warning above.
|
||||
VideoNormalEvent.KIND,
|
||||
VideoShortEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Route for an event we hold only a NIP-19 pointer to — its id plus, when the pointer carries
|
||||
* one, its [kind] — because the body has not reached [LocalCache] yet.
|
||||
*
|
||||
* Notification deep links are the reason this exists. A push normally wakes a *cold* process,
|
||||
* so by the time the user taps the tray the cache is empty and [routeFor] can say nothing
|
||||
* better than [Route.EventRedirect] — a bare "looking for event" screen the user sits on until
|
||||
* the event is re-fetched. An `nevent` already states the kind, which for an ordinary note is
|
||||
* the whole answer, so a reply or a mention can open its thread immediately and let the screen
|
||||
* fill itself in.
|
||||
*
|
||||
* Returns null when the kind is absent or needs the body to place the event, leaving the
|
||||
* caller's redirect in charge.
|
||||
*/
|
||||
fun routeForPointer(
|
||||
kind: Int?,
|
||||
id: HexKey,
|
||||
): Route? {
|
||||
if (kind == null) return null
|
||||
if (kind !in THREAD_VIEW_KINDS) return null
|
||||
return Route.Note(id)
|
||||
}
|
||||
|
||||
fun routeFor(
|
||||
note: Note,
|
||||
loggedIn: Account,
|
||||
|
||||
@@ -1081,6 +1081,12 @@ sealed class Route {
|
||||
|
||||
@Serializable data class EventRedirect(
|
||||
val id: String,
|
||||
/**
|
||||
* The event is a NIP-17 rumor, so [id] is a private event id that must never reach
|
||||
* a relay: the screen watches LocalCache for it and issues no REQ. The envelope that
|
||||
* carries it comes back on its own through the always-on gift-wrap tail.
|
||||
*/
|
||||
val isPrivate: Boolean = false,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.service.notifications.ConversationShortcuts
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
@@ -398,6 +399,11 @@ class AccountSessionManager(
|
||||
// `:napplet` call ProfileStore.deleteProfile(name) — and that must refuse a profile still in
|
||||
// use by a live WebView. Not wired for this release; there is no existing hook that reaches
|
||||
// the sandbox on account deletion.
|
||||
// The launcher is the one place an account's contacts live outside our own
|
||||
// storage, so the conversation shortcuts go before anything else — whether or not
|
||||
// this is the account currently on screen. See [ConversationShortcuts].
|
||||
ConversationShortcuts.removeForAccount(Amethyst.instance.appContext, accountInfo.npub)
|
||||
|
||||
if (accountInfo.npub == currentAccountNPub()) {
|
||||
// Drop the Nest bridge ref before tearing down the
|
||||
// current account so the audio-room activity can't
|
||||
|
||||
+17
@@ -360,6 +360,18 @@ class GiftWrapEventHandler(
|
||||
eventProcessor.consumeEvent(innerGift, innerGiftNote, publicNote)
|
||||
}
|
||||
}
|
||||
|
||||
// The wrap note already emitted once — from `consumeRegularEvent`, before any of this
|
||||
// ran, when `innerEventId` was still null and the note said nothing about where it
|
||||
// leads. Everything above is plain assignment, so without this the wrap's last word on
|
||||
// itself is that first, useless emission: anything waiting on it (a `nostr:nevent…` for
|
||||
// a wrap, a notification from a build that addressed DMs that way) waits forever while
|
||||
// the message it wanted sits decrypted in the cache. Emitted last so an observer that
|
||||
// wakes here can walk wrap → seal → rumor and find the whole chain linked.
|
||||
//
|
||||
// `flowSet?`, not `flow()`: a wrap nobody is watching — which is nearly all of them,
|
||||
// a week's worth on every cold start — must not be given a flow set to hold.
|
||||
eventNote.flowSet?.metadata?.invalidateData()
|
||||
}
|
||||
|
||||
private suspend fun processExistingGiftWrap(
|
||||
@@ -505,6 +517,11 @@ class SealedRumorEventHandler(
|
||||
} else {
|
||||
eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote)
|
||||
}
|
||||
|
||||
// Same as the wrap above: the seal's only emission came before it was opened, so
|
||||
// re-emit now that it points at its rumor. This runs inside the wrap's own unwrap, so
|
||||
// the wrap's emission lands after it and sees a fully linked chain.
|
||||
eventNote.flowSet?.metadata?.invalidateData()
|
||||
}
|
||||
|
||||
private suspend fun processExistingSealedRumor(
|
||||
|
||||
+72
-18
@@ -22,56 +22,118 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.looking_for_event
|
||||
import com.vitorpamplona.amethyst.commons.resources.looking_for_event_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.looking_for_private_event
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteLocally
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The waiting room for an event we can only name: hold here until it arrives, then replace
|
||||
* this entry with wherever it actually belongs.
|
||||
*
|
||||
* It is a real screen, not a bare label. A notification tap on a cold process lands here
|
||||
* (LocalCache is empty until the event is re-fetched), and with nothing but a line of text
|
||||
* on `colorScheme.background` — black on the dark theme — the wait read as the app opening
|
||||
* to a black screen, with no top bar and no way back other than the system gesture.
|
||||
*/
|
||||
@Composable
|
||||
fun LoadRedirectScreen(
|
||||
eventId: String?,
|
||||
isPrivate: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
) {
|
||||
if (eventId == null) return
|
||||
|
||||
LoadNote(eventId, accountViewModel) { note ->
|
||||
note?.let {
|
||||
LoadRedirectScreen(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = { Text(stringRes(Res.string.looking_for_event_title)) },
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).padding(horizontal = 50.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
|
||||
LoadNote(eventId, accountViewModel) { note ->
|
||||
Text(
|
||||
// A private id is not something to show a user, and it must not be
|
||||
// copyable off the screen either.
|
||||
text =
|
||||
if (isPrivate) {
|
||||
stringRes(Res.string.looking_for_private_event)
|
||||
} else {
|
||||
stringRes(Res.string.looking_for_event, note?.idHex ?: eventId)
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
)
|
||||
|
||||
note?.let {
|
||||
WatchAndRedirect(
|
||||
baseNote = it,
|
||||
isPrivate = isPrivate,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders nothing: it only watches [baseNote] and pops this entry for the real destination
|
||||
* the moment the event lands.
|
||||
*
|
||||
* A public note is watched through [observeNote], which also holds a relay subscription open
|
||||
* so the note is actually fetched. A private one is watched through [observeNoteLocally],
|
||||
* which asks no relay: a rumor id must never appear in a REQ, and it does not need to — the
|
||||
* envelope carrying it is re-fetched by the always-on gift-wrap tail, and unwrapping it fires
|
||||
* this flow.
|
||||
*/
|
||||
@Composable
|
||||
fun LoadRedirectScreen(
|
||||
private fun WatchAndRedirect(
|
||||
baseNote: Note,
|
||||
isPrivate: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteState by observeNote(baseNote, accountViewModel)
|
||||
val noteState by if (isPrivate) observeNoteLocally(baseNote) else observeNote(baseNote, accountViewModel)
|
||||
|
||||
LaunchedEffect(key1 = noteState) {
|
||||
val event = noteState.note.event
|
||||
@@ -83,12 +145,4 @@ fun LoadRedirectScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(stringRes(Res.string.looking_for_event, baseNote.idHex))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1002,11 @@
|
||||
|
||||
<string name="app_notification_reply_label">Reply</string>
|
||||
<string name="app_notification_mark_read_label">Mark Read</string>
|
||||
<string name="app_notification_reply_sending">Sending…</string>
|
||||
<string name="app_notification_reply_unconfirmed">Still sending…</string>
|
||||
<string name="app_notification_reply_failed">Not sent</string>
|
||||
<string name="app_notification_reply_retry">Retry</string>
|
||||
<string name="app_notification_shortcut_account_removed">This account was removed from Amethyst.</string>
|
||||
<string name="app_notification_me">Me</string>
|
||||
<string name="app_notification_new_message">New message</string>
|
||||
<string name="app_notification_added_to_group">You\'ve been added to %1$s</string>
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The identity a conversation is published under.
|
||||
*
|
||||
* A launcher shortcut is not a notification — it persists, it is what the system matches a
|
||||
* notification against to grant it the Conversations treatment, and
|
||||
* [ConversationShortcuts.removeForAccount] finds an account's shortcuts by pattern-matching
|
||||
* these strings on logout. So the id has to be stable for the same chat, distinct for
|
||||
* different ones, and readably scoped to its account.
|
||||
*/
|
||||
class ConversationShortcutIdTest {
|
||||
private val npub = "npub1" + "q".repeat(58)
|
||||
private val otherNpub = "npub1" + "p".repeat(58)
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
|
||||
/**
|
||||
* The one that would actually break. A [ChatroomKey] is a *set*, so two arrivals in the
|
||||
* same group DM can iterate its members in different orders. Joining them unsorted would
|
||||
* mint a second id for a chat that already has a shortcut — a duplicate entry in the
|
||||
* launcher, and a notification whose `setShortcutId` points at whichever copy lost.
|
||||
*/
|
||||
@Test
|
||||
fun aRoomHasOneIdRegardlessOfMemberOrder() {
|
||||
val oneWay = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice, bob)), npub)
|
||||
val theOther = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(bob, alice)), npub)
|
||||
|
||||
assertEquals(oneWay, theOther)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSameChatUnderTwoAccountsIsTwoConversations() {
|
||||
val mine = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), npub)
|
||||
val theirs = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), otherNpub)
|
||||
|
||||
assertNotEquals(
|
||||
"one launcher entry shared by two logins would open the wrong inbox",
|
||||
mine,
|
||||
theirs,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentChatsUnderOneAccountStayApart() {
|
||||
val withAlice = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), npub)
|
||||
val withBob = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(bob)), npub)
|
||||
val withBoth = NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice, bob)), npub)
|
||||
|
||||
assertEquals(3, setOf(withAlice, withBob, withBoth).size)
|
||||
}
|
||||
|
||||
/** A DM, a Marmot group and a relay group named by the same hex are three rooms, not one. */
|
||||
@Test
|
||||
fun eachKindOfRoomHasItsOwnNamespace() {
|
||||
val ids =
|
||||
setOf(
|
||||
NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice)), npub),
|
||||
NotificationRoutes.marmotShortcutId(alice, npub),
|
||||
NotificationRoutes.relayGroupShortcutId(alice, npub),
|
||||
)
|
||||
|
||||
assertEquals(3, ids.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* [ConversationShortcuts.removeForAccount] selects on `":$npub:"`, so every id has to carry
|
||||
* the account delimited that way or a logout would leave that account's contacts in the
|
||||
* launcher.
|
||||
*/
|
||||
@Test
|
||||
fun everyIdIsFindableByTheAccountScopeLogoutSweepsOn() {
|
||||
val scope = ":$npub:"
|
||||
|
||||
listOf(
|
||||
NotificationRoutes.chatroomShortcutId(ChatroomKey(setOf(alice, bob)), npub),
|
||||
NotificationRoutes.marmotShortcutId(alice, npub),
|
||||
NotificationRoutes.relayGroupShortcutId("naddr1abc", npub),
|
||||
).forEach {
|
||||
assertTrue("'$it' is not sweepable on logout", it.contains(scope))
|
||||
}
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.chatroomRoute
|
||||
import com.vitorpamplona.amethyst.ui.isChatroomRoute
|
||||
import com.vitorpamplona.amethyst.ui.isPrivateNoteRoute
|
||||
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
|
||||
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.privateNoteRoute
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* The deep links a tapped notification hands to `MainActivity.uriToRoute`, checked at the
|
||||
* string level — these are what decide whether a tap lands on a real screen or on the
|
||||
* "looking for event" redirect.
|
||||
*/
|
||||
class NotificationDeepLinkTest {
|
||||
private val npub = "npub1" + "q".repeat(58)
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
|
||||
@Test
|
||||
fun chatroomUriCarriesBothParticipantsAndTheAccount() {
|
||||
val uri = NotificationRoutes.chatroomUri(ChatroomKey(setOf(alice)), npub)
|
||||
|
||||
assertTrue(isChatroomRoute(uri))
|
||||
assertEquals(alice, uri.findQueryParameterValue("id"))
|
||||
assertEquals(npub, uri.findQueryParameterValue("account"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatroomUriKeepsEveryMemberOfAGroupDm() {
|
||||
val uri = NotificationRoutes.chatroomUri(ChatroomKey(setOf(alice, bob)), npub)
|
||||
|
||||
assertEquals(setOf(alice, bob), uri.findQueryParameterValue("id")?.split(',')?.toSet())
|
||||
assertEquals(npub, uri.findQueryParameterValue("account"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression: `marmot:<hex>?account=…` is an *opaque* URI, so `java.net.URI` keeps the
|
||||
* query inside the scheme-specific part and reports no query at all. Reading the account
|
||||
* that way returned null on every Marmot notification, and the tap opened the group under
|
||||
* whichever account happened to be current instead of switching to the addressee first.
|
||||
*/
|
||||
@Test
|
||||
fun opaqueMarmotUriStillYieldsItsAccount() {
|
||||
val uri = NotificationRoutes.marmotUri("d".repeat(64), npub)
|
||||
|
||||
assertNull(URI(uri).findParameterValue("account"))
|
||||
assertEquals(npub, uri.findQueryParameterValue("account"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hierarchicalNotificationUrisAreReadTheSameWay() {
|
||||
val uri = NotificationRoutes.notificationsUri(npub, "c".repeat(64))
|
||||
|
||||
assertEquals(npub, uri.findQueryParameterValue("account"))
|
||||
assertEquals("c".repeat(64), uri.findQueryParameterValue("scrollTo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aUriWithoutAQueryHasNoParameters() {
|
||||
assertNull("nevent1qqsabcdef".findQueryParameterValue("account"))
|
||||
assertNull("".findQueryParameterValue("account"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyParameterValueReadsAsAbsent() {
|
||||
assertNull("chatroom?id=&account=$npub".findQueryParameterValue("id"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A rumor's `nevent` names the gift wrap that delivered it, never the rumor — that is the
|
||||
* citation rule `RumorHostCitationTest` pins, and it is why a private note cannot be
|
||||
* deep-linked the ordinary way: the wrap is unfetchable from the account's read relays and
|
||||
* its note stops emitting before it is ever decrypted. So `noteUri` has to switch shapes on
|
||||
* its own; every renderer calls it and none of them knows whether its note arrived sealed.
|
||||
*/
|
||||
@Test
|
||||
fun aPrivateNoteIsAddressedByItsRumorIdNotItsEnvelope() {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val rumor = signer.sign(TextNoteEvent.build("psst"))
|
||||
val wrap = GiftWrapEvent.create(rumor, signer.pubKey)
|
||||
|
||||
val note = Note(rumor.id)
|
||||
note.event = rumor
|
||||
note.recordRumorHost(wrap)
|
||||
|
||||
val uri = NotificationRoutes.noteUri(note, npub)
|
||||
|
||||
assertTrue(isPrivateNoteRoute(uri))
|
||||
assertEquals(rumor.id, uri.findQueryParameterValue("id"))
|
||||
assertEquals(npub, uri.findQueryParameterValue("account"))
|
||||
assertFalse("the deep link must not name the wrap", uri.contains(wrap.id))
|
||||
assertFalse("and must not be an nevent of it", uri.startsWith("nevent1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPublicNoteKeepsItsNeventDeepLink() {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val event = signer.sign(TextNoteEvent.build("hello world"))
|
||||
|
||||
val note = Note(event.id)
|
||||
note.event = event
|
||||
|
||||
val uri = NotificationRoutes.noteUri(note, npub)
|
||||
|
||||
assertEquals(NEvent.create(event.id, null, event.kind, null) + "?account=" + npub, uri)
|
||||
}
|
||||
|
||||
/** The route a private link produces must be flagged, or its screen would REQ the rumor id. */
|
||||
@Test
|
||||
fun thePrivateRouteIsMarkedPrivate() {
|
||||
val uri = NotificationRoutes.privateNoteUri(alice, npub)
|
||||
|
||||
assertEquals(Route.EventRedirect(alice, isPrivate = true), privateNoteRoute(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPrivateLinkWithoutAnIdIsNotARoute() {
|
||||
assertNull(privateNoteRoute("privatenote?id=&account=$npub"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The destination, not just the link: a DM opens the conversation it belongs to. A kind-14
|
||||
* is a [com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable], so both ways into it agree —
|
||||
* the room link a notification now carries, and `routeFor` on the event itself, which is how
|
||||
* the wrap-shaped links still sitting in trays from an older build resolve once the envelope
|
||||
* is opened. Neither is a thread: the thread view is where a *private note* goes, which is a
|
||||
* NIP-17-wrapped kind 1 — the same envelope, but a note posted privately rather than a
|
||||
* message in a room.
|
||||
*/
|
||||
@Test
|
||||
fun aDmOpensItsConversationByEitherRoute() {
|
||||
val me = NostrSignerSync(KeyPair())
|
||||
val them = NostrSignerSync(KeyPair())
|
||||
val account = mockk<Account>(relaxed = true)
|
||||
every { account.userProfile().pubkeyHex } returns me.pubKey
|
||||
|
||||
val room = ChatroomKey(setOf(them.pubKey))
|
||||
val expected = Route.Room(room)
|
||||
|
||||
assertEquals(expected, uriToRoute(NotificationRoutes.chatroomUri(room, npub), account))
|
||||
|
||||
val dm = them.sign(ChatMessageEvent.build("hi", listOf(PTag(me.pubKey, null))))
|
||||
assertEquals(expected, routeFor(dm, account))
|
||||
}
|
||||
|
||||
/**
|
||||
* A room link resolves without touching the account.
|
||||
*
|
||||
* It must not: `uriToRoute` runs against whichever account is current, *before* the
|
||||
* `?account=` switch, so registering the room here would file a DM for account B under
|
||||
* account A. And `nostr:` is exported and browsable, so a web page can post one of these —
|
||||
* which is also why the ids are checked rather than taken as given. The screen registers the
|
||||
* room itself when it opens, on the account that is current by then.
|
||||
*/
|
||||
@Test
|
||||
fun aRoomLinkResolvesWithoutTouchingTheAccount() {
|
||||
val account = mockk<Account>(relaxed = true)
|
||||
val uri = NotificationRoutes.chatroomUri(ChatroomKey(setOf(alice)), npub)
|
||||
|
||||
assertEquals(Route.Room(ChatroomKey(setOf(alice))), uriToRoute(uri, account))
|
||||
|
||||
verify(exactly = 0) { account.chatroomList }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRoomLinkThatIsNotMadeOfPubkeysIsNotARoute() {
|
||||
assertNull(chatroomRoute("chatroom?id=not-a-pubkey&account=$npub"))
|
||||
assertNull(chatroomRoute("chatroom?id=${alice.dropLast(1)}&account=$npub"))
|
||||
assertNull(chatroomRoute("chatroom?id=${alice.uppercase()}&account=$npub"))
|
||||
// one bad member poisons the whole key — a room is the exact set or nothing
|
||||
assertNull(chatroomRoute("chatroom?id=$alice,nope&account=$npub"))
|
||||
assertNull(chatroomRoute("chatroom?id=&account=$npub"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Why a screen waiting on a gift wrap has to be woken a second time.
|
||||
*
|
||||
* `LocalCache.consumeRegularEvent` emits the wrap note the moment the wrap is cached — which
|
||||
* is before anything has decrypted it, so `innerEventId` is still null and the wrap can say
|
||||
* nothing about where it leads. Everything that links the chain afterwards is plain assignment
|
||||
* on the note, which emits nothing. So that first emission is the only one a
|
||||
* `Route.EventRedirect` parked on a wrap ever sees, and the case below is what it sees: no
|
||||
* route, nothing to navigate to, wait forever — while the message it wanted sits decrypted in
|
||||
* the cache a few milliseconds later. `DecryptAndIndexProcessor` re-emits the wrap and seal
|
||||
* notes once the chain is linked, which is what turns the second case into the live one.
|
||||
*/
|
||||
class GiftWrapRedirectRouteTest {
|
||||
private val account = mockk<Account>()
|
||||
private val signer = NostrSignerSync(KeyPair())
|
||||
|
||||
private fun wrap(): GiftWrapEvent = GiftWrapEvent.create(signer.sign(TextNoteEvent.build("psst")), signer.pubKey)
|
||||
|
||||
@Test
|
||||
fun anUnopenedWrapHasNowhereToGo() {
|
||||
assertNull(routeFor(wrap(), account))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOpenedWrapLeadsToWhatItCarried() {
|
||||
val sealId = "5".repeat(64)
|
||||
val opened = wrap().apply { innerEventId = sealId }
|
||||
|
||||
// The seal itself has not been cached in this test, so the walk stops there and hands
|
||||
// back a redirect — the point is that it moves at all, which it cannot do until the
|
||||
// wrap has been opened.
|
||||
assertEquals(Route.EventRedirect(sealId), routeFor(opened, account))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.THREAD_VIEW_KINDS
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A notification tap on a cold process finds nothing in LocalCache, so the only thing left
|
||||
* to route on is what the `nevent` itself states. Ordinary notes are fully described by
|
||||
* their kind and can open their thread right away; anything that needs a tag out of the
|
||||
* body must keep waiting on the redirect.
|
||||
*/
|
||||
class RouteForPointerTest {
|
||||
private val id = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun plainNoteKindsOpenTheirThread() {
|
||||
assertEquals(Route.Note(id), routeForPointer(TextNoteEvent.KIND, id))
|
||||
assertEquals(Route.Note(id), routeForPointer(CommentEvent.KIND, id))
|
||||
assertEquals(Route.Note(id), routeForPointer(PictureEvent.KIND, id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPointerWithoutAKindCannotDecide() {
|
||||
assertNull(routeForPointer(null, id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kindsThatNeedTheBodyKeepWaiting() {
|
||||
// channel id lives in an `e` tag
|
||||
assertNull(routeForPointer(ChannelMessageEvent.KIND, id))
|
||||
// chatroom key lives in the `p` tags of the rumor
|
||||
assertNull(routeForPointer(ChatMessageEvent.KIND, id))
|
||||
// the wrap has to be opened before it can say anything
|
||||
assertNull(routeForPointer(GiftWrapEvent.KIND, id))
|
||||
// addressables are cited by `a` tag, not by id
|
||||
assertNull(routeForPointer(LongTextNoteEvent.KIND, id))
|
||||
// a kind:0 IS the person — Route.Profile, not a note
|
||||
assertNull(routeForPointer(MetadataEvent.KIND, id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant the shortcut rests on, checked against the thing it claims to mirror rather
|
||||
* than against a second hand-written list: for every kind it answers for, the answer must be
|
||||
* the one [routeFor] gives once the body finally arrives. Walking `THREAD_VIEW_KINDS` itself
|
||||
* means a kind added to it is covered by construction.
|
||||
*
|
||||
* This is what catches an addressable kind slipping in. `routeForInner` routes those by
|
||||
* `addressTag()`, so the shortcut's `Route.Note(id)` would point at a version id that no
|
||||
* relay will serve — a silent dead end, and exactly what the NIP-71 video pair did.
|
||||
*/
|
||||
@Test
|
||||
fun everyShortcutKindAgreesWithTheRouteItsBodyWouldHaveTaken() {
|
||||
val account = mockk<Account>(relaxed = true)
|
||||
|
||||
THREAD_VIEW_KINDS.forEach { kind ->
|
||||
val event: Event =
|
||||
EventFactory.create(id, "b".repeat(64), 1, kind, emptyArray(), "", "c".repeat(128))
|
||||
|
||||
assertEquals(
|
||||
"kind $kind (${event::class.simpleName}) disagrees with routeFor",
|
||||
routeFor(event, account),
|
||||
routeForPointer(kind, id),
|
||||
)
|
||||
assertEquals("kind $kind must resolve to the thread view", Route.Note(id), routeForPointer(kind, id))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression itself. 21/22 are regular video and take the shortcut; 34235/34236 extend
|
||||
* `AddressableVideoEvent`, are cited by `naddr`, and must wait for the body.
|
||||
*/
|
||||
@Test
|
||||
fun addressableVideoKindsDoNotTakeTheShortcut() {
|
||||
assertNull(routeForPointer(VideoHorizontalEvent.KIND, id))
|
||||
assertNull(routeForPointer(VideoVerticalEvent.KIND, id))
|
||||
}
|
||||
}
|
||||
@@ -1346,6 +1346,8 @@
|
||||
<string name="poll_closing_date_time">Poll Closing Date & Time</string>
|
||||
<string name="poll_closing_in">Poll closes in %1$s</string>
|
||||
<string name="looking_for_event">Looking for Event %1$s</string>
|
||||
<string name="looking_for_event_title">Loading</string>
|
||||
<string name="looking_for_private_event">Waiting for your private messages to load…</string>
|
||||
<string name="send_zap">Send Zap</string>
|
||||
<string name="custom_zaps_add_a_message">Add a public message</string>
|
||||
<string name="custom_zaps_add_a_message_private">Add a private message</string>
|
||||
|
||||
Reference in New Issue
Block a user