diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
index fa8b4e31a..bc3c42c84 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
@@ -38,8 +38,10 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
+import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.InlineReplyTarget
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
+import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendMentionNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReplyNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
@@ -665,14 +667,19 @@ class EventNotificationConsumer(
// don't notify for own notes
if (event.pubKey == account.signer.pubKey) return
- // must be a reply (NIP-10) — plain mentions are not handled on this channel.
- val replyTargetId = event.replyingTo() ?: return
+ val replyTargetId = event.replyingTo()
- // only notify when the reply targets a note authored by the current account.
- val repliedNote = LocalCache.getNoteIfExists(replyTargetId) ?: return
- if (repliedNote.author?.pubkeyHex != account.signer.pubKey) return
+ if (replyTargetId != null) {
+ val repliedNote = LocalCache.getNoteIfExists(replyTargetId)
+ if (repliedNote?.author?.pubkeyHex == account.signer.pubKey) {
+ val threadRoot = event.markedRoot()?.eventId ?: event.unmarkedRoot()?.eventId ?: replyTargetId
+ notifyReply(event, account, repliedNote.event?.content, threadRoot)
+ return
+ }
+ }
- notifyReply(event, account, repliedNote.event?.content)
+ // Not a reply to us but we're p-tagged — a mention or citation.
+ notifyMention(event, account)
}
private suspend fun notify(
@@ -698,13 +705,20 @@ class EventNotificationConsumer(
.replyingTo()
?.let { LocalCache.getNoteIfExists(it)?.event?.content }
- notifyReply(event, account, parentContent)
+ val threadRoot =
+ event.rootEventIds().firstOrNull()
+ ?: event.rootAddressIds().firstOrNull()
+ ?: event.replyingToAddressOrEvent()
+ ?: event.id
+
+ notifyReply(event, account, parentContent, threadRoot)
}
private suspend fun notifyReply(
event: Event,
account: Account,
parentContent: String?,
+ threadRootId: String,
) {
val replyNote = LocalCache.getNoteIfExists(event.id) ?: return
@@ -747,13 +761,52 @@ class EventNotificationConsumer(
notificationManager()
.sendReplyNotification(
- event.id,
- content,
- title,
- event.createdAt,
- userPicture,
- noteUri,
- applicationContext,
+ id = event.id,
+ messageBody = content,
+ messageTitle = title,
+ time = event.createdAt,
+ pictureUrl = userPicture,
+ uri = noteUri,
+ applicationContext = applicationContext,
+ threadRootId = threadRootId,
+ inlineReply = InlineReplyTarget(accountNpub = accountNpub, targetEventId = event.id),
+ )
+ }
+
+ private suspend fun notifyMention(
+ event: TextNoteEvent,
+ account: Account,
+ ) {
+ val note = LocalCache.getNoteIfExists(event.id) ?: return
+
+ val author = LocalCache.getOrCreateUser(event.pubKey)
+ val user = author.toBestDisplayName()
+ val userPicture = author.profilePicture()
+
+ val title = stringRes(applicationContext, R.string.app_notification_mentions_channel_message, user)
+
+ val content =
+ event.content
+ .split("\n")
+ .firstOrNull { it.isNotBlank() }
+ ?.take(280)
+ ?: ""
+
+ val accountNpub =
+ account.signer.pubKey
+ .hexToByteArray()
+ .toNpub()
+ val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
+
+ notificationManager()
+ .sendMentionNotification(
+ id = event.id,
+ messageBody = content,
+ messageTitle = title,
+ time = event.createdAt,
+ pictureUrl = userPicture,
+ uri = noteUri,
+ applicationContext = applicationContext,
)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt
index c51877b63..f8d8923af 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt
@@ -28,8 +28,12 @@ import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
+import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -69,32 +73,56 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return
- val pendingResult = goAsync()
- val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
- scope.launch {
- // activates the relay to send the message.
- val collectionJob =
- scope.launch {
- Amethyst.instance.relayProxyClientConnector.relayServices
- .collect()
- }
-
- try {
- sendReply(accountNpub, members, replyText)
- notificationManager.cancel(notificationId)
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
- } finally {
- pendingResult.finish()
-
- // closes the relay connection.
- collectionJob.cancel()
- scope.cancel()
- }
+ runOnRelay(notificationManager, notificationId) {
+ sendReply(accountNpub, members, replyText)
}
}
+
+ NotificationUtils.PUBLIC_REPLY_ACTION -> {
+ val replyText =
+ RemoteInput
+ .getResultsFromIntent(intent)
+ ?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
+ ?.toString()
+
+ 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) {
+ sendPublicReply(accountNpub, targetEventId, replyText)
+ }
+ }
+ }
+ }
+
+ private fun runOnRelay(
+ notificationManager: NotificationManager,
+ notificationId: Int,
+ block: suspend () -> Unit,
+ ) {
+ val pendingResult = goAsync()
+ val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ scope.launch {
+ val collectionJob =
+ scope.launch {
+ Amethyst.instance.relayProxyClientConnector.relayServices
+ .collect()
+ }
+
+ try {
+ block()
+ notificationManager.cancel(notificationId)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
+ } finally {
+ pendingResult.finish()
+ collectionJob.cancel()
+ scope.cancel()
+ }
}
}
@@ -111,4 +139,42 @@ class NotificationReplyReceiver : BroadcastReceiver() {
account.sendNip17PrivateMessage(template)
}
+
+ private suspend fun sendPublicReply(
+ accountNpub: String,
+ targetEventId: String,
+ replyText: String,
+ ) {
+ val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
+ val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
+
+ val targetEvent = LocalCache.getNoteIfExists(targetEventId)?.event ?: return
+
+ val template =
+ when (targetEvent) {
+ is TextNoteEvent -> {
+ TextNoteEvent.build(
+ note = replyText,
+ replyingTo = EventHintBundle(targetEvent),
+ )
+ }
+
+ is CommentEvent -> {
+ CommentEvent.replyBuilder(
+ msg = replyText,
+ replyingTo = EventHintBundle(targetEvent),
+ )
+ }
+
+ else -> {
+ // Non-threaded events (e.g. long-form articles) use NIP-22 comments.
+ CommentEvent.replyBuilder(
+ msg = replyText,
+ replyingTo = EventHintBundle(targetEvent),
+ )
+ }
+ }
+
+ account.signAndComputeBroadcast(template)
+ }
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt
index b1815d605..7ff9d453f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt
@@ -49,25 +49,39 @@ object NotificationUtils {
private var reactionChannel: NotificationChannel? = null
private var chessChannel: NotificationChannel? = null
private var replyChannel: NotificationChannel? = null
+ private var mentionChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
- private const val REPLY_GROUP_KEY = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION"
+ const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION"
+ private const val MENTION_GROUP_KEY = "com.vitorpamplona.amethyst.MENTION_NOTIFICATION"
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
+ const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
const val KEY_NOTIFICATION_ID = "key_notification_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"
private const val DM_SUMMARY_ID = 0x10000
private const val ZAP_SUMMARY_ID = 0x20000
private const val REACTION_SUMMARY_ID = 0x40000
private const val CHESS_SUMMARY_ID = 0x30000
- private const val REPLY_SUMMARY_ID = 0x50000
+ private const val REPLY_SUMMARY_ID_BASE = 0x50000
+ private const val MENTION_SUMMARY_ID = 0x60000
+
+ /**
+ * Derives a stable summary notification id for a per-thread reply group.
+ * Uses the thread root id hash mixed with the base id so different threads
+ * don't collide with each other or with the other channel summaries.
+ */
+ fun replySummaryIdFor(threadRootId: String): Int = REPLY_SUMMARY_ID_BASE xor threadRootId.hashCode()
+
+ fun replyGroupKeyFor(threadRootId: String): String = "$REPLY_GROUP_KEY_PREFIX:$threadRootId"
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
@@ -174,6 +188,27 @@ object NotificationUtils {
return replyChannel!!
}
+ fun getOrCreateMentionChannel(applicationContext: Context): NotificationChannel {
+ if (mentionChannel != null) return mentionChannel!!
+
+ mentionChannel =
+ NotificationChannel(
+ stringRes(applicationContext, R.string.app_notification_mentions_channel_id),
+ stringRes(applicationContext, R.string.app_notification_mentions_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description =
+ stringRes(applicationContext, R.string.app_notification_mentions_channel_description)
+ }
+
+ val notificationManager: NotificationManager =
+ applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ notificationManager.createNotificationChannel(mentionChannel!!)
+
+ return mentionChannel!!
+ }
+
suspend fun NotificationManager.sendReactionNotification(
id: String,
messageBody: String,
@@ -238,6 +273,8 @@ object NotificationUtils {
pictureUrl: String?,
uri: String,
applicationContext: Context,
+ threadRootId: String,
+ inlineReply: InlineReplyTarget? = null,
) {
getOrCreateReplyChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_replies_channel_id)
@@ -250,14 +287,54 @@ object NotificationUtils {
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
- notificationGroupKey = REPLY_GROUP_KEY,
+ notificationGroupKey = replyGroupKeyFor(threadRootId),
category = NotificationCompat.CATEGORY_SOCIAL,
- summaryId = REPLY_SUMMARY_ID,
+ summaryId = replySummaryIdFor(threadRootId),
summaryText = stringRes(applicationContext, R.string.app_notification_replies_summary),
applicationContext = applicationContext,
+ inlineReply = inlineReply,
+ )
+ }
+
+ suspend fun NotificationManager.sendMentionNotification(
+ id: String,
+ messageBody: String,
+ messageTitle: String,
+ time: Long,
+ pictureUrl: String?,
+ uri: String,
+ applicationContext: Context,
+ ) {
+ getOrCreateMentionChannel(applicationContext)
+ val channelId = stringRes(applicationContext, R.string.app_notification_mentions_channel_id)
+
+ sendNotification(
+ id = id,
+ messageBody = messageBody,
+ messageTitle = messageTitle,
+ time = time,
+ pictureUrl = pictureUrl,
+ uri = uri,
+ channelId = channelId,
+ notificationGroupKey = MENTION_GROUP_KEY,
+ category = NotificationCompat.CATEGORY_SOCIAL,
+ summaryId = MENTION_SUMMARY_ID,
+ summaryText = stringRes(applicationContext, R.string.app_notification_mentions_summary),
+ applicationContext = applicationContext,
)
}
+ /**
+ * Payload for wiring a RemoteInput-powered inline reply action onto a public
+ * note notification. The receiver resolves the target event from LocalCache
+ * via [targetEventId] and signs the appropriate kind (1 for NIP-10, 1111
+ * for NIP-22) under the account identified by [accountNpub].
+ */
+ data class InlineReplyTarget(
+ val accountNpub: String,
+ val targetEventId: String,
+ )
+
suspend fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
@@ -490,6 +567,7 @@ object NotificationUtils {
summaryId: Int,
summaryText: String,
applicationContext: Context,
+ inlineReply: InlineReplyTarget? = null,
) {
val notId = id.hashCode()
@@ -536,6 +614,40 @@ object NotificationUtils {
.setAutoCancel(true)
.setWhen(time * 1000)
+ if (inlineReply != null) {
+ val remoteInput =
+ RemoteInput
+ .Builder(KEY_REPLY_TEXT)
+ .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
+ .build()
+
+ val replyIntent =
+ Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
+ action = PUBLIC_REPLY_ACTION
+ putExtra(KEY_NOTIFICATION_ID, notId)
+ putExtra(KEY_ACCOUNT_NPUB, inlineReply.accountNpub)
+ putExtra(KEY_TARGET_EVENT_ID, inlineReply.targetEventId)
+ }
+
+ val replyPendingIntent =
+ PendingIntent.getBroadcast(
+ applicationContext,
+ notId,
+ replyIntent,
+ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ val replyAction =
+ NotificationCompat.Action
+ .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
+ .addRemoteInput(remoteInput)
+ .setAllowGeneratedReplies(true)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .build()
+
+ builder.addAction(replyAction)
+ }
+
notify(notId, builder.build())
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 43745ce20..c7446c01b 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -882,6 +882,11 @@
%1$s replied
on: %1$s
New replies
+ MentionsID
+ Mentions
+ Notifies you when somebody mentions or cites you in a post
+ %1$s mentioned you
+ New mentions
Incoming calls