Merge pull request #1878 from vitorpamplona/claude/event-sync-screen-sYGtN

Claude/event sync screen
This commit is contained in:
Vitor Pamplona
2026-03-19 15:36:38 -04:00
committed by GitHub
10 changed files with 326 additions and 396 deletions
@@ -26,14 +26,16 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
@@ -44,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.Privat
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.coroutines.launch
@Composable
@@ -92,10 +95,15 @@ fun ChatroomView(
}
}
if (room.users.size == 1) {
// Activates NIP-17 if the user has DM relays
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) {
newPostModel.nip17 = !it?.relays().isNullOrEmpty()
// Reactively check if recipients have DM relays for NIP-17 delivery
for (userHex in room.users) {
LoadAddressableNote(
ChatMessageRelayListEvent.createAddress(userHex),
accountViewModel,
) { note ->
if (note != null) {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
}
}
}
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
@@ -88,16 +89,29 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
@Stable
@@ -126,9 +140,57 @@ class ChatNewMessageViewModel :
}
}
var room: ChatroomKey? by mutableStateOf(null)
val room = MutableStateFlow<ChatroomKey?>(null)
var requiresNIP17: Boolean = false
val roomUsers: StateFlow<List<User>> =
room
.mapNotNull {
it?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList()
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
room.value?.users?.mapNotNull { userHex -> LocalCache.checkGetOrCreateUser(userHex) } ?: emptyList(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val recipientsMissingDmRelays: StateFlow<ImmutableList<User>> =
roomUsers
.transformLatest {
val dmRelayListNoteFlows =
it.map { user ->
user.dmRelayListNote
.flow()
.metadata.stateFlow
}
if (dmRelayListNoteFlows.isEmpty()) {
emitAll(MutableStateFlow(persistentListOf()))
} else {
val flow =
combine(dmRelayListNoteFlows) { dmRelayListNotes ->
dmRelayListNotes
.mapNotNull { noteState ->
val noteEvent = noteState.note.event as? ChatMessageRelayListEvent
if (noteEvent == null || noteEvent.relays().isEmpty()) {
noteState.note.author
} else {
null
}
}.toImmutableList()
}
emitAll(flow)
}
}.onStart {
}.onCompletion {
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
roomUsers.value
.filter { user ->
user.dmInboxRelays().isNullOrEmpty()
}.toImmutableList(),
)
val replyTo = mutableStateOf<Note?>(null)
@@ -180,9 +242,6 @@ class ChatNewMessageViewModel :
var wantsZapraiser by mutableStateOf(false)
override var zapRaiserAmount = mutableStateOf<Long?>(null)
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -208,26 +267,11 @@ class ChatNewMessageViewModel :
}
fun load(room: ChatroomKey) {
this.room = room
this.room.tryEmit(room)
this.toUsers =
TextFieldValue(
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
updateNIP17StatusFromRoom()
}
fun updateNIP17StatusFromRoom() {
val room = this.room
if (room != null) {
this.requiresNIP17 = room.users.size > 1
if (this.requiresNIP17) {
this.nip17 = true
}
} else {
this.requiresNIP17 = false
this.nip17 = false
}
}
fun reply(replyNote: Note) {
@@ -360,9 +404,6 @@ class ChatNewMessageViewModel :
urlPreviews.update(message)
iMetaAttachments.addAll(draftEvent.imetas())
requiresNIP17 = draftEvent is NIP17Group
nip17 = draftEvent is NIP17Group
}
suspend fun sendPostSync() {
@@ -402,27 +443,19 @@ class ChatNewMessageViewModel :
val uploadState = uploadState ?: return
accountViewModel.launchSigner {
if (nip17) {
ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.HOLD
},
context,
) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.HOLD
},
context,
) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
}
}
@@ -432,34 +465,26 @@ class ChatNewMessageViewModel :
context: Context,
onceUploaded: () -> Unit,
) {
val room = room ?: return
val room = room.value ?: return
val uploadState = uploadState ?: return
accountViewModel.launchSigner {
if (nip17) {
ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.SEND
pendingRetryOnError = onError
pendingRetryContext = context
pendingRetryOnceUploaded = onceUploaded
},
context,
) {
ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded()
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context) {
ChatFileSender(room, account).sendNIP04(it)
draftTag.newVersion()
onceUploaded()
}
ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.SEND
pendingRetryOnError = onError
pendingRetryContext = context
pendingRetryOnceUploaded = onceUploaded
},
context,
) {
ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded()
}
}
}
@@ -488,12 +513,12 @@ class ChatNewMessageViewModel :
val onError = pendingRetryOnError
val context = pendingRetryContext
val onceUploaded = pendingRetryOnceUploaded
val room = room
val room = room.value
val uploadState = uploadState
dismissEncryptedUploadError()
if (uploadState == null || context == null) return
if (room == null || uploadState == null || context == null) return
uploadState.encryptFiles = false
@@ -512,7 +537,6 @@ class ChatNewMessageViewModel :
}
RetryMode.SEND -> {
if (room == null) return@launchSigner
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
@@ -528,7 +552,7 @@ class ChatNewMessageViewModel :
}
private suspend fun innerSendPost(draftTag: String?) {
val room = room ?: return
val room = room.value ?: return
val urls = findURLs(message.text)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -541,70 +565,49 @@ class ChatNewMessageViewModel :
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) {
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
val template =
if (replyHint == null) {
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
val template =
if (replyHint == null) {
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
} else {
ChatMessageEvent.reply(message, replyHint) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
}
} else {
val toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }
val template =
PrivateDmEvent.build(
toUser = toUser,
message = message,
imetas = usedAttachments,
replyingTo = replyTo.value?.toEventHint<PrivateDmEvent>(),
signer = accountViewModel.account.signer,
) {
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
emojis(emojis)
imetas(usedAttachments)
}
} else {
accountViewModel.account.sendNip04PrivateMessage(template)
ChatMessageEvent.reply(message, replyHint) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
}
if (draftTag == null) {
ChatFileSender(room, accountViewModel.account).sendAll(uploadsWaitingToBeSent)
ChatFileSender(room, accountViewModel.account).sendNIP17(uploadsWaitingToBeSent)
}
}
@@ -699,12 +702,10 @@ class ChatNewMessageViewModel :
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
if (users.isNullOrEmpty()) {
room = null
updateNIP17StatusFromRoom()
room.emit(null)
} else {
if (users != room?.users) {
room = ChatroomKey(users)
updateNIP17StatusFromRoom()
if (users != room.value?.users) {
room.emit(ChatroomKey(users))
}
}
}
@@ -737,9 +738,6 @@ class ChatNewMessageViewModel :
val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
updateRoomFromUsersInput()
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm()
nip17 = relayList != null
}
userSuggestionsMainMessage = null
@@ -783,7 +781,8 @@ class ChatNewMessageViewModel :
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) &&
(toUsers.text.isNotBlank()) &&
uploadState?.multiOrchestrator == null
uploadState?.multiOrchestrator == null &&
recipientsMissingDmRelays.value.isEmpty()
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
@@ -795,15 +794,7 @@ class ChatNewMessageViewModel :
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
}
fun toggleNIP04And24() {
if (requiresNIP17) {
nip17 = true
} else {
nip17 = !nip17
}
draftTag.newVersion()
}
// NIP-04 sending is deprecated. NIP-17 is always used.
override fun updateZapPercentage(
index: Int,
@@ -197,7 +197,7 @@ fun NewGroupDMScreen(
// function when the postViewModel is released
accountViewModel.launchSigner {
postViewModel.sendPostSync()
postViewModel.room?.let {
postViewModel.room.value?.let {
nav.nav(routeToMessage(it, null, null, null, null, accountViewModel))
}
}
@@ -332,6 +332,11 @@ fun GroupDMScreenContent(
)
}
val missingRelays by postViewModel.recipientsMissingDmRelays.collectAsStateWithLifecycle()
if (missingRelays.isNotEmpty()) {
RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav)
}
BottomRowActions(postViewModel, accountViewModel)
}
}
@@ -393,7 +398,9 @@ private fun BottomRowActions(
.height(50.dp),
verticalAlignment = CenterVertically,
) {
if (postViewModel.room != null) {
val room by postViewModel.room.collectAsStateWithLifecycle()
if (room != null) {
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
@@ -424,7 +431,7 @@ private fun BottomRowActions(
}
}
if (postViewModel.room != null) {
if (room != null) {
TakePictureButton(
onPictureTaken = { postViewModel.pickedMedia(it) },
)
@@ -443,7 +450,7 @@ private fun BottomRowActions(
}
}
if (postViewModel.room != null) {
if (room != null) {
TakeVideoButton(
onVideoTaken = { postViewModel.pickedMedia(it) },
)
@@ -550,8 +557,6 @@ fun SendDirectMessageTo(
focusedBorderColor = Color.Transparent,
),
)
ToggleNip17Button(postViewModel, accountViewModel)
}
HorizontalDivider(thickness = DividerThickness)
@@ -20,35 +20,50 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.R.attr.maxLines
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog
@@ -61,9 +76,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.PostKeyboard
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Preview
@Composable
@@ -164,7 +183,12 @@ fun PrivateMessageEditFieldRow(
}
}
EditField(channelScreenModel, onSendNewMessage, accountViewModel)
val missingRelays by channelScreenModel.recipientsMissingDmRelays.collectAsStateWithLifecycle()
if (missingRelays.isNotEmpty()) {
RecipientMissingRelaysWarning(missingRelays, accountViewModel, nav)
} else {
EditField(channelScreenModel, onSendNewMessage, accountViewModel)
}
}
}
@@ -209,6 +233,90 @@ fun EditField(
)
}
@Preview
@Composable
fun RecipientMissingRelaysWarningPreview() {
val user1 = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
val user2 = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b")
ThemeComparisonColumn {
RecipientMissingRelaysWarning(
persistentListOf(user1, user2),
mockAccountViewModel(),
EmptyNav(),
)
}
}
@Composable
fun RecipientMissingRelaysWarning(
users: ImmutableList<User>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy10dp,
) {
UserGallery(users) { user ->
ClickableUserPicture(
user,
Size25dp,
accountViewModel,
onClick = {
nav.nav { routeFor(user) }
},
)
}
Text(
text = stringRes(R.string.recipient_missing_dm_relays),
color = MaterialTheme.colorScheme.error,
fontSize = Font12SP,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
fun UserGallery(
users: ImmutableList<User>,
galleryUser: @Composable RowScope.(user: User) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy((-10).dp),
) {
users.take(6).forEach {
key(it.pubkeyHex) {
galleryUser(it)
}
}
if (users.size > 6) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(Size25dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = "+" + showCount(users.size - 6),
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@Composable
fun KeyboardLeadingIcon(
channelScreenModel: ChatNewMessageViewModel,
@@ -216,7 +324,7 @@ fun KeyboardLeadingIcon(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 10.dp),
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) {
SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage,
@@ -224,8 +332,6 @@ fun KeyboardLeadingIcon(
modifier = Modifier,
onImageChosen = channelScreenModel::pickedMedia,
)
ToggleNip17Button(channelScreenModel, accountViewModel)
}
}
@@ -25,94 +25,25 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
@Composable
fun ToggleNip17Button(
channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
) {
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
if (wantsToActivateNIP17) {
NewFeatureNIP17AlertDialog(
accountViewModel = accountViewModel,
onConfirm = { channelScreenModel.toggleNIP04And24() },
onDismiss = { wantsToActivateNIP17 = false },
)
}
fun Nip17Indicator(channelScreenModel: ChatNewMessageViewModel) {
IconButton(
modifier = Modifier.width(30.dp),
onClick = {
if (
!accountViewModel.account.settings.hideNIP17WarningDialog &&
!channelScreenModel.nip17 &&
!channelScreenModel.requiresNIP17
) {
wantsToActivateNIP17 = true
} else {
channelScreenModel.toggleNIP04And24()
}
},
onClick = { },
enabled = false,
) {
if (channelScreenModel.nip17) {
Icon(
painter = painterRes(R.drawable.incognito, 2),
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
modifier = IncognitoIconButtonModifier,
tint = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
painter = painterRes(R.drawable.incognito_off, 2),
contentDescription = stringRes(id = R.string.accessibility_turn_on_sealed_message),
modifier = IncognitoIconButtonModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
Icon(
painter = painterRes(R.drawable.incognito, 2),
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
modifier = IncognitoIconButtonModifier,
tint = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
fun NewFeatureNIP17AlertDialog(
accountViewModel: AccountViewModel,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog(
title = stringRes(R.string.new_feature_nip17_might_not_be_available_title),
textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description),
buttonIconResource = R.drawable.incognito,
buttonIconReference = 3,
buttonText = stringRes(R.string.new_feature_nip17_activate),
onClickDoOnce = {
scope.launch { onConfirm() }
onDismiss()
},
onClickDontShowAgain = {
scope.launch {
onConfirm()
accountViewModel.account.settings.setHideNIP17WarningDialog()
}
onDismiss()
},
onDismiss = onDismiss,
)
}
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -100,47 +99,7 @@ class ChatFileSender(
account.sendNip17PrivateMessage(template)
}
// ------
// NIP 04
// ------
suspend fun sendNIP04(uploads: List<SuccessfulUploads>) {
uploads.forEach {
if (it.cipher == null) {
sendNIP04(it.result, it.caption, it.contentWarningReason)
}
}
}
suspend fun sendNIP04(
result: UploadOrchestrator.OrchestratorResult.ServerResult,
caption: String?,
contentWarningReason: String?,
) {
val iMetaAttachments = IMetaAttachments()
iMetaAttachments.add(result, caption, contentWarningReason)
val toUser = chatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }
val template =
PrivateDmEvent.build(
toUser = toUser,
message = result.url,
imetas = iMetaAttachments.iMetaAttachments,
replyingTo = null,
signer = account.signer,
)
account.sendNip04PrivateMessage(template)
}
suspend fun sendAll(uploads: List<SuccessfulUploads>) {
uploads.forEach {
if (it.cipher != null) {
sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher)
} else {
sendNIP04(it.result, it.caption, it.contentWarningReason)
}
}
sendNIP17(uploads)
}
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -46,7 +47,8 @@ fun RoomChatFileUploadDialog(
accountViewModel: AccountViewModel,
nav: INav,
) {
val room = channelScreenModel.room ?: return
val roomState = channelScreenModel.room.collectAsStateWithLifecycle()
val room = roomState.value ?: return
val context = LocalContext.current
ChatFileUploadDialog(
@@ -74,6 +76,6 @@ fun RoomChatFileUploadDialog(
onCancel,
accountViewModel,
nav,
isNip17 = channelScreenModel.nip17,
isNip17 = true,
)
}
+1
View File
@@ -1351,6 +1351,7 @@
<string name="dm_relays_not_found_examples2">Good options are:\n - auth.nostr1.com (free)\n - inbox.nostr.wine (paid)\n - relay.0xchat.com (free)</string>
<string name="dm_relays_not_found_editing">Insert between 13 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them.</string>
<string name="dm_relays_not_found_create_now">Set up now</string>
<string name="recipient_missing_dm_relays">DM inbox relays not found. Messages cannot be delivered until they configure their relay list.</string>
<string name="search_relays_title">Search Relays</string>
<string name="search_relays_not_found">Set up your Search relays</string>
@@ -28,13 +28,11 @@ import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip19Bech32.toNpub
@@ -62,9 +60,6 @@ class ChatNewMessageState(
private val _message = MutableStateFlow(TextFieldValue(""))
val message: StateFlow<TextFieldValue> = _message.asStateFlow()
private val _nip17 = MutableStateFlow(false)
val nip17: StateFlow<Boolean> = _nip17.asStateFlow()
private val _replyTo = MutableStateFlow<Note?>(null)
val replyTo: StateFlow<Note?> = _replyTo.asStateFlow()
@@ -74,38 +69,37 @@ class ChatNewMessageState(
private val _room = MutableStateFlow<ChatroomKey?>(null)
val room: StateFlow<ChatroomKey?> = _room.asStateFlow()
/** Whether NIP-17 is required (group chat with >1 recipient) */
private val _requiresNip17 = MutableStateFlow(false)
val requiresNip17: StateFlow<Boolean> = _requiresNip17.asStateFlow()
/** Whether any recipients are missing DM relay lists, preventing message delivery */
private val _recipientsMissingDmRelays = MutableStateFlow(false)
val recipientsMissingDmRelays: StateFlow<Boolean> = _recipientsMissingDmRelays.asStateFlow()
/** Whether a message can be sent (non-blank text + room set) */
/** Whether a message can be sent (non-blank text + room set + all recipients have DM relays) */
val canSend: Boolean
get() = _message.value.text.isNotBlank() && _room.value != null
get() = _message.value.text.isNotBlank() && _room.value != null && !_recipientsMissingDmRelays.value
/**
* Load a chatroom. Sets the room key, formats toUsers display,
* and auto-detects NIP-17 requirement (group chats require NIP-17).
* Load a chatroom. Sets the room key and checks recipient DM relay availability.
*/
fun load(roomKey: ChatroomKey) {
_room.value = roomKey
updateNip17FromRoom()
updateRecipientRelayStatus()
}
/**
* Auto-detect NIP-17 based on room:
* - Group chats (>1 recipient) always require NIP-17
* - Single recipient: NIP-17 off by default (can be toggled)
* Check if all recipients have DM relay lists.
* Messages can only be sent via NIP-17, so recipients must have
* either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
*/
fun updateNip17FromRoom() {
fun updateRecipientRelayStatus() {
val currentRoom = _room.value
if (currentRoom != null) {
_requiresNip17.value = currentRoom.users.size > 1
if (_requiresNip17.value) {
_nip17.value = true
}
_recipientsMissingDmRelays.value =
currentRoom.users.any { hexKey ->
val user = cache.getOrCreateUser(hexKey) as? User
user?.dmInboxRelays().isNullOrEmpty()
}
} else {
_requiresNip17.value = false
_nip17.value = false
_recipientsMissingDmRelays.value = false
}
}
@@ -126,27 +120,7 @@ class ChatNewMessageState(
}
/**
* Toggle NIP-04/NIP-17 mode.
* If NIP-17 is required (group chat), stays on NIP-17.
*/
fun toggleNip17() {
if (_requiresNip17.value) {
_nip17.value = true
} else {
_nip17.value = !_nip17.value
}
}
/**
* Enable NIP-17 (e.g., when recipient has DM relay list).
*/
fun enableNip17() {
_nip17.value = true
}
/**
* Send the current message. Builds the appropriate event template
* (NIP-04 or NIP-17) and delegates to IAccount for signing/broadcasting.
* Send the current message as NIP-17. NIP-04 is deprecated for sending.
*
* @return true if send was initiated, false if preconditions not met
*/
@@ -154,12 +128,9 @@ class ChatNewMessageState(
val currentRoom = _room.value ?: return false
val messageText = _message.value.text
if (messageText.isBlank()) return false
if (_recipientsMissingDmRelays.value) return false
if (_nip17.value || currentRoom.users.size > 1 || _replyTo.value?.event is NIP17Group) {
sendNip17(currentRoom, messageText)
} else {
sendNip04(currentRoom, messageText)
}
sendNip17(currentRoom, messageText)
return true
}
@@ -193,23 +164,6 @@ class ChatNewMessageState(
account.sendNip17PrivateMessage(template)
}
private suspend fun sendNip04(
room: ChatroomKey,
messageText: String,
) {
val toUser = (cache.getOrCreateUser(room.users.first()) as? User)?.toPTag() ?: return
val template =
PrivateDmEvent.build(
toUser = toUser,
message = messageText,
replyingTo = _replyTo.value?.toEventHint<PrivateDmEvent>(),
signer = account.signer,
)
account.sendNip04PrivateMessage(template)
}
/**
* Clear all composition state after sending or cancelling.
*/
@@ -150,25 +150,18 @@ fun ChatPane(
val scope = rememberCoroutineScope()
val feedState by feedViewModel.feedState.feedContent.collectAsState()
val messageText by messageState.message.collectAsState()
val isNip17 by messageState.nip17.collectAsState()
val requiresNip17 by messageState.requiresNip17.collectAsState()
val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState()
// File attachment state
val attachedFiles = remember { mutableStateListOf<File>() }
var isUploading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
// Helper: attach files and auto-force NIP-17 if needed
// Helper: attach files
fun attachFiles(files: List<File>) {
val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }
if (mediaFiles.isEmpty()) return
attachedFiles.addAll(mediaFiles)
if (!isNip17) {
messageState.toggleNip17()
scope.launch {
snackbarHostState.showSnackbar("Switched to NIP-17 — file attachments require encrypted messaging")
}
}
}
// Drag-and-drop target for file attachments (NIP-17 only)
@@ -346,13 +339,11 @@ fun ChatPane(
// Message input
MessageInput(
messageText = messageText.text,
isNip17 = isNip17,
requiresNip17 = requiresNip17,
recipientsMissingRelays = recipientsMissingRelays,
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
isUploading = isUploading,
hasAttachments = attachedFiles.isNotEmpty(),
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
onToggleNip17 = { messageState.toggleNip17() },
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
onSend = {
scope.launch {
@@ -698,13 +689,11 @@ private fun ReactionBar(onReaction: (String) -> Unit) {
@Composable
private fun MessageInput(
messageText: String,
isNip17: Boolean,
requiresNip17: Boolean,
recipientsMissingRelays: Boolean,
canSend: Boolean,
isUploading: Boolean = false,
hasAttachments: Boolean = false,
onMessageChange: (String) -> Unit,
onToggleNip17: () -> Unit,
onAttach: () -> Unit = {},
onSend: () -> Unit,
) {
@@ -773,46 +762,30 @@ private fun MessageInput(
}
}
// NIP-17 indicator
// NIP-17 indicator / recipient warning
Spacer(Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp),
) {
IconButton(
onClick = onToggleNip17,
enabled = !requiresNip17,
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = if (isNip17) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = if (isNip17) "NIP-17 (encrypted)" else "NIP-04 (legacy)",
modifier = Modifier.size(16.dp),
tint =
if (isNip17) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
)
}
Icon(
imageVector = Icons.Default.Lock,
contentDescription = "NIP-17 (encrypted)",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(4.dp))
Text(
text = if (isNip17) "NIP-17" else "NIP-04",
text = "NIP-17",
style = MaterialTheme.typography.labelSmall,
color =
if (isNip17) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
color = MaterialTheme.colorScheme.primary,
)
if (requiresNip17) {
Spacer(Modifier.width(4.dp))
if (recipientsMissingRelays) {
Spacer(Modifier.width(8.dp))
Text(
text = "(required for groups)",
text = "Recipient has no DM relay list — messages cannot be delivered",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
color = MaterialTheme.colorScheme.error,
)
}
}