mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #2995 from vitorpamplona/claude/test-mls-reply-notifications-SnaIM
Add reply support for Marmot/MLS group messages
This commit is contained in:
@@ -2132,6 +2132,28 @@ class Account(
|
||||
|
||||
// --- Marmot Group Messaging ---
|
||||
|
||||
/**
|
||||
* Resolve the relay set for a Marmot group. Prefer the relays carried in
|
||||
* the MLS GroupContext metadata so every member converges on the same
|
||||
* canonical set; fall back to the account's outbox relays if the group
|
||||
* has none (e.g. a group joined before MIP-01 metadata existed).
|
||||
*
|
||||
* Lives on Account (not AccountViewModel) so that headless callers —
|
||||
* notifications' BroadcastReceiver, background workers — can resolve
|
||||
* relays without spinning up a ViewModel.
|
||||
*/
|
||||
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
|
||||
val groupRelays =
|
||||
marmotManager
|
||||
?.groupMetadata(nostrGroupId)
|
||||
?.relays
|
||||
?.mapNotNull {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalizeOrNull(it)
|
||||
}?.toSet()
|
||||
return if (!groupRelays.isNullOrEmpty()) groupRelays else outboxRelays.flow.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a Marmot MLS group.
|
||||
* Encrypts the inner event and publishes the GroupEvent to group relays.
|
||||
|
||||
+3
@@ -586,6 +586,9 @@ class EventNotificationConsumer(
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
chatroomMembers = null,
|
||||
marmotNostrGroupId = nostrGroupId,
|
||||
marmotReplyToInnerEventId = innerEvent.id,
|
||||
marmotReplyToInnerAuthor = innerEvent.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+47
@@ -94,6 +94,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
sendPublicReply(accountNpub, targetEventId, replyText)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationUtils.MARMOT_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 nostrGroupId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_GROUP_ID) ?: return
|
||||
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) {
|
||||
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +159,34 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
account.sendNip17PrivateMessage(template)
|
||||
}
|
||||
|
||||
private suspend fun sendMarmotReply(
|
||||
accountNpub: String,
|
||||
nostrGroupId: String,
|
||||
replyToInnerEventId: String?,
|
||||
replyToInnerAuthor: String?,
|
||||
replyText: String,
|
||||
) {
|
||||
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
|
||||
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
|
||||
|
||||
val manager = account.marmotManager ?: return
|
||||
|
||||
// Use id+author from the Intent so the reply is threaded even when
|
||||
// LocalCache hasn't been rehydrated yet (cold-process broadcast
|
||||
// receiver: Account.restoreAll runs async on init and may not have
|
||||
// finished by the time we get here).
|
||||
val bundle =
|
||||
manager.buildTextMessage(
|
||||
nostrGroupId = nostrGroupId,
|
||||
text = replyText,
|
||||
replyToEventId = replyToInnerEventId,
|
||||
replyToAuthorPubKey = replyToInnerAuthor,
|
||||
persistOwn = false,
|
||||
)
|
||||
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
|
||||
}
|
||||
|
||||
private suspend fun sendPublicReply(
|
||||
accountNpub: String,
|
||||
targetEventId: String,
|
||||
|
||||
+57
@@ -60,12 +60,16 @@ object NotificationUtils {
|
||||
|
||||
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
|
||||
const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_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"
|
||||
const val KEY_MARMOT_GROUP_ID = "key_marmot_group_id"
|
||||
const val KEY_MARMOT_REPLY_TO_INNER_ID = "key_marmot_reply_to_inner_id"
|
||||
const val KEY_MARMOT_REPLY_TO_INNER_AUTHOR = "key_marmot_reply_to_inner_author"
|
||||
|
||||
private const val DM_SUMMARY_ID = 0x10000
|
||||
private const val ZAP_SUMMARY_ID = 0x20000
|
||||
@@ -374,6 +378,9 @@ object NotificationUtils {
|
||||
accountNpub: String? = null,
|
||||
accountPictureUrl: String? = null,
|
||||
chatroomMembers: String? = null,
|
||||
marmotNostrGroupId: String? = null,
|
||||
marmotReplyToInnerEventId: String? = null,
|
||||
marmotReplyToInnerAuthor: String? = null,
|
||||
) {
|
||||
getOrCreateDMChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
|
||||
@@ -390,6 +397,9 @@ object NotificationUtils {
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = accountPictureUrl,
|
||||
chatroomMembers = chatroomMembers,
|
||||
marmotNostrGroupId = marmotNostrGroupId,
|
||||
marmotReplyToInnerEventId = marmotReplyToInnerEventId,
|
||||
marmotReplyToInnerAuthor = marmotReplyToInnerAuthor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,6 +435,9 @@ object NotificationUtils {
|
||||
accountNpub: String?,
|
||||
accountPictureUrl: String?,
|
||||
chatroomMembers: String?,
|
||||
marmotNostrGroupId: String? = null,
|
||||
marmotReplyToInnerEventId: String? = null,
|
||||
marmotReplyToInnerAuthor: String? = null,
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
@@ -522,6 +535,50 @@ object NotificationUtils {
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
|
||||
builder.addAction(replyAction)
|
||||
} else if (accountNpub != null && marmotNostrGroupId != null) {
|
||||
// Marmot/MLS Reply action: sends the user's text as an encrypted
|
||||
// kind:9 inside the Marmot group, replying to the inner event
|
||||
// that triggered this notification. Mirrors the NIP-17 path
|
||||
// above but routes through NotificationReplyReceiver's
|
||||
// MARMOT_REPLY_ACTION branch so we never publish a plaintext
|
||||
// public reply for an encrypted group message.
|
||||
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 = MARMOT_REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, accountNpub)
|
||||
putExtra(KEY_MARMOT_GROUP_ID, marmotNostrGroupId)
|
||||
if (marmotReplyToInnerEventId != null) {
|
||||
putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, marmotReplyToInnerEventId)
|
||||
}
|
||||
if (marmotReplyToInnerAuthor != null) {
|
||||
putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, marmotReplyToInnerAuthor)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -368,7 +368,15 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MarmotGroupChat> {
|
||||
MarmotGroupChatScreen(
|
||||
nostrGroupId = it.nostrGroupId,
|
||||
draftMessage = it.message,
|
||||
replyToInnerNote = it.replyId,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||
|
||||
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -252,6 +252,15 @@ fun routeReplyTo(
|
||||
note: Note,
|
||||
account: Account,
|
||||
): Route? {
|
||||
// Marmot group messages must reply inside the encrypted group, not as a
|
||||
// public kind:1111 comment. The inner kind:9 event has no group hint of
|
||||
// its own — we detect the group via the gathering MarmotGroupChatroom,
|
||||
// mirroring routeFor() above.
|
||||
val marmotGroup = note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
|
||||
if (marmotGroup != null) {
|
||||
return Route.MarmotGroupChat(marmotGroup.nostrGroupId, replyId = note.idHex)
|
||||
}
|
||||
|
||||
val noteEvent = note.event
|
||||
return when (noteEvent) {
|
||||
is ChannelMessageEvent -> {
|
||||
|
||||
@@ -404,6 +404,8 @@ sealed class Route {
|
||||
|
||||
@Serializable data class MarmotGroupChat(
|
||||
val nostrGroupId: String,
|
||||
val message: String? = null,
|
||||
val replyId: HexKey? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class MarmotGroupInfo(
|
||||
|
||||
+19
-24
@@ -1541,12 +1541,23 @@ class AccountViewModel(
|
||||
suspend fun sendMarmotGroupMessage(
|
||||
nostrGroupId: String,
|
||||
text: String,
|
||||
replyToInnerEventId: HexKey? = null,
|
||||
replyToInnerAuthorPubKey: HexKey? = null,
|
||||
) {
|
||||
// Inner event construction lives on MarmotManager so CLI and UI don't drift.
|
||||
// persistOwn=false because Account.sendMarmotGroupMessage routes the outer
|
||||
// event through LocalCache which already handles own-message display.
|
||||
val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
val bundle =
|
||||
account.marmotManager
|
||||
?.buildTextMessage(
|
||||
nostrGroupId = nostrGroupId,
|
||||
text = text,
|
||||
replyToEventId = replyToInnerEventId,
|
||||
replyToAuthorPubKey = replyToInnerAuthorPubKey,
|
||||
persistOwn = false,
|
||||
)
|
||||
?: return
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||
}
|
||||
|
||||
@@ -1576,7 +1587,7 @@ class AccountViewModel(
|
||||
account.signer.pubKey,
|
||||
template,
|
||||
)
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
||||
}
|
||||
|
||||
@@ -1614,7 +1625,7 @@ class AccountViewModel(
|
||||
}
|
||||
|
||||
suspend fun leaveMarmotGroup(nostrGroupId: String) {
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.leaveMarmotGroup(nostrGroupId, relays)
|
||||
}
|
||||
|
||||
@@ -1622,22 +1633,6 @@ class AccountViewModel(
|
||||
account.resetMarmotState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relay set for a Marmot group from MLS GroupContext metadata.
|
||||
* Falls back to outbox relays if the group has no configured relays.
|
||||
*/
|
||||
private fun marmotGroupRelays(nostrGroupId: String): Set<NormalizedRelayUrl> {
|
||||
val metadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
||||
val groupRelays =
|
||||
metadata
|
||||
?.relays
|
||||
?.mapNotNull {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalizeOrNull(it)
|
||||
}?.toSet()
|
||||
return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value
|
||||
}
|
||||
|
||||
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
|
||||
|
||||
suspend fun addMarmotGroupMember(
|
||||
@@ -1649,7 +1644,7 @@ class AccountViewModel(
|
||||
nostrGroupId: String,
|
||||
targetLeafIndex: Int,
|
||||
) {
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
|
||||
}
|
||||
|
||||
@@ -1657,7 +1652,7 @@ class AccountViewModel(
|
||||
nostrGroupId: String,
|
||||
targetPubKey: String,
|
||||
) {
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
}
|
||||
|
||||
@@ -1665,7 +1660,7 @@ class AccountViewModel(
|
||||
nostrGroupId: String,
|
||||
targetPubKey: String,
|
||||
) {
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
}
|
||||
|
||||
@@ -1697,7 +1692,7 @@ class AccountViewModel(
|
||||
name = name,
|
||||
description = description,
|
||||
)
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -51,6 +51,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@Composable
|
||||
fun MarmotGroupChatScreen(
|
||||
nostrGroupId: HexKey,
|
||||
draftMessage: String? = null,
|
||||
replyToInnerNote: HexKey? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -127,6 +129,8 @@ fun MarmotGroupChatScreen(
|
||||
Column(Modifier.padding(it)) {
|
||||
MarmotGroupChatView(
|
||||
nostrGroupId = nostrGroupId,
|
||||
draftMessage = draftMessage,
|
||||
replyToInnerNote = replyToInnerNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
+51
-3
@@ -29,11 +29,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -47,6 +50,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
@@ -58,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@@ -74,6 +79,8 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun MarmotGroupChatView(
|
||||
nostrGroupId: HexKey,
|
||||
draftMessage: String? = null,
|
||||
replyToInnerNote: HexKey? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -99,6 +106,27 @@ fun MarmotGroupChatView(
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
val messageState = remember(nostrGroupId) { TextFieldState() }
|
||||
val replyTo = remember(nostrGroupId) { mutableStateOf<Note?>(null) }
|
||||
|
||||
// Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS
|
||||
// message in the Notifications screen) into the actual Note once it has
|
||||
// landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids.
|
||||
if (replyToInnerNote != null) {
|
||||
LaunchedEffect(replyToInnerNote) {
|
||||
val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote)
|
||||
if (parent != null) {
|
||||
replyTo.value = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (draftMessage != null) {
|
||||
LaunchedEffect(draftMessage) {
|
||||
messageState.setTextAndPlaceCursorAtEnd(draftMessage)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -111,7 +139,7 @@ fun MarmotGroupChatView(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
routeForLastRead = "MarmotGroup/$nostrGroupId",
|
||||
onWantsToReply = { },
|
||||
onWantsToReply = { note -> replyTo.value = note },
|
||||
onWantsToEditDraft = { },
|
||||
)
|
||||
}
|
||||
@@ -120,6 +148,8 @@ fun MarmotGroupChatView(
|
||||
|
||||
MarmotGroupMessageComposer(
|
||||
nostrGroupId = nostrGroupId,
|
||||
messageState = messageState,
|
||||
replyTo = replyTo,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onMessageSent = {
|
||||
@@ -132,12 +162,13 @@ fun MarmotGroupChatView(
|
||||
@Composable
|
||||
fun MarmotGroupMessageComposer(
|
||||
nostrGroupId: HexKey,
|
||||
messageState: TextFieldState,
|
||||
replyTo: MutableState<Note?>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onMessageSent: suspend () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val messageState = remember { TextFieldState() }
|
||||
val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } }
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -162,6 +193,12 @@ fun MarmotGroupMessageComposer(
|
||||
)
|
||||
}
|
||||
|
||||
replyTo.value?.let {
|
||||
DisplayReplyingToNote(it, accountViewModel, nav) {
|
||||
replyTo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = EditFieldModifier) {
|
||||
ThinPaddingTextField(
|
||||
state = messageState,
|
||||
@@ -191,10 +228,21 @@ fun MarmotGroupMessageComposer(
|
||||
) {
|
||||
val text = messageState.text.toString().trim()
|
||||
if (text.isNotEmpty()) {
|
||||
// Capture id+pubKey snapshot under the value? guard so
|
||||
// a slow send doesn't race a user-cleared reply state.
|
||||
val parentEvent = replyTo.value?.event
|
||||
val replyId = parentEvent?.id
|
||||
val replyAuthor = parentEvent?.pubKey
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
accountViewModel.sendMarmotGroupMessage(nostrGroupId, text)
|
||||
accountViewModel.sendMarmotGroupMessage(
|
||||
nostrGroupId = nostrGroupId,
|
||||
text = text,
|
||||
replyToInnerEventId = replyId,
|
||||
replyToInnerAuthorPubKey = replyAuthor,
|
||||
)
|
||||
messageState.clearText()
|
||||
replyTo.value = null
|
||||
onMessageSent()
|
||||
} catch (e: Exception) {
|
||||
launch(Dispatchers.Main) {
|
||||
|
||||
+22
-1
@@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
@@ -182,11 +184,30 @@ class MarmotManager(
|
||||
suspend fun buildTextMessage(
|
||||
nostrGroupId: HexKey,
|
||||
text: String,
|
||||
replyToEventId: HexKey? = null,
|
||||
replyToAuthorPubKey: HexKey? = null,
|
||||
persistOwn: Boolean = true,
|
||||
): TextMessageBundle {
|
||||
val template =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.eventTemplate<Event>(kind = 9, description = text)
|
||||
.eventTemplate<Event>(kind = 9, description = text) {
|
||||
if (replyToEventId != null) {
|
||||
// Mirror ChatEvent.reply(): NIP-18 q-tag references the
|
||||
// parent inner kind:9 by id (+ optional author, no
|
||||
// relay hint — the inner rumor never hits a relay
|
||||
// directly). Taking id+pubKey separately (rather than
|
||||
// the full parent Event) lets the push-notification
|
||||
// reply path produce a threaded reply from cold start,
|
||||
// when LocalCache hasn't been re-hydrated yet.
|
||||
quote(
|
||||
QEventTag(
|
||||
eventId = replyToEventId,
|
||||
relayHint = null,
|
||||
authorPubKeyHex = replyToAuthorPubKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val innerEvent =
|
||||
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
.assembleRumor<Event>(signer.pubKey, template)
|
||||
|
||||
Reference in New Issue
Block a user