Merge branch 'main-upstream' into claude/strip-file-metadata-sVIEd

This commit is contained in:
davotoula
2026-03-19 20:49:15 +01:00
12 changed files with 538 additions and 397 deletions
@@ -26,14 +26,16 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView 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.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -92,10 +95,15 @@ fun ChatroomView(
} }
} }
if (room.users.size == 1) { // Reactively check if recipients have DM relays for NIP-17 delivery
// Activates NIP-17 if the user has DM relays for (userHex in room.users) {
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { LoadAddressableNote(
newPostModel.nip17 = !it?.relays().isNullOrEmpty() ChatMessageRelayListEvent.createAddress(userHex),
accountViewModel,
) { note ->
if (note != null) {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
}
} }
} }
@@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent 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.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
@@ -89,16 +90,29 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest 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 import kotlinx.coroutines.launch
@Stable @Stable
@@ -127,9 +141,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) val replyTo = mutableStateOf<Note?>(null)
@@ -185,9 +247,6 @@ class ChatNewMessageViewModel :
var wantsZapraiser by mutableStateOf(false) var wantsZapraiser by mutableStateOf(false)
override var zapRaiserAmount = mutableStateOf<Long?>(null) override var zapRaiserAmount = mutableStateOf<Long?>(null)
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().lnAddress() fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -214,26 +273,11 @@ class ChatNewMessageViewModel :
} }
fun load(room: ChatroomKey) { fun load(room: ChatroomKey) {
this.room = room this.room.tryEmit(room)
this.toUsers = this.toUsers =
TextFieldValue( TextFieldValue(
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, 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) { fun reply(replyNote: Note) {
@@ -366,9 +410,6 @@ class ChatNewMessageViewModel :
urlPreviews.update(message) urlPreviews.update(message)
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
requiresNIP17 = draftEvent is NIP17Group
nip17 = draftEvent is NIP17Group
} }
suspend fun sendPostSync() { suspend fun sendPostSync() {
@@ -408,28 +449,20 @@ class ChatNewMessageViewModel :
val uploadState = uploadState ?: return val uploadState = uploadState ?: return
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { ChatFileUploader(account).justUploadNIP17(
ChatFileUploader(account).justUploadNIP17( uploadState,
uploadState, onError,
onError, onEncryptedUploadError = { title, message ->
onEncryptedUploadError = { title, message -> encryptedUploadErrorTitle = title
encryptedUploadErrorTitle = title encryptedUploadErrorMessage = message
encryptedUploadErrorMessage = message pendingRetryMode = RetryMode.HOLD
pendingRetryMode = RetryMode.HOLD },
}, context,
context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) {
) { uploadsWaitingToBeSent += it
uploadsWaitingToBeSent += it draftTag.newVersion()
draftTag.newVersion() onceUploaded()
onceUploaded()
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded()
}
} }
} }
} }
@@ -439,35 +472,27 @@ class ChatNewMessageViewModel :
context: Context, context: Context,
onceUploaded: () -> Unit, onceUploaded: () -> Unit,
) { ) {
val room = room ?: return val room = room.value ?: return
val uploadState = uploadState ?: return val uploadState = uploadState ?: return
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { ChatFileUploader(account).justUploadNIP17(
ChatFileUploader(account).justUploadNIP17( uploadState,
uploadState, onError,
onError, onEncryptedUploadError = { title, message ->
onEncryptedUploadError = { title, message -> encryptedUploadErrorTitle = title
encryptedUploadErrorTitle = title encryptedUploadErrorMessage = message
encryptedUploadErrorMessage = message pendingRetryMode = RetryMode.SEND
pendingRetryMode = RetryMode.SEND pendingRetryOnError = onError
pendingRetryOnError = onError pendingRetryContext = context
pendingRetryContext = context pendingRetryOnceUploaded = onceUploaded
pendingRetryOnceUploaded = onceUploaded },
}, context,
context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) {
) { ChatFileSender(room, account).sendNIP17(it)
ChatFileSender(room, account).sendNIP17(it) draftTag.newVersion()
draftTag.newVersion() onceUploaded()
onceUploaded()
}
} else {
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation) {
ChatFileSender(room, account).sendNIP04(it)
draftTag.newVersion()
onceUploaded()
}
} }
} }
} }
@@ -496,12 +521,12 @@ class ChatNewMessageViewModel :
val onError = pendingRetryOnError val onError = pendingRetryOnError
val context = pendingRetryContext val context = pendingRetryContext
val onceUploaded = pendingRetryOnceUploaded val onceUploaded = pendingRetryOnceUploaded
val room = room val room = room.value
val uploadState = uploadState val uploadState = uploadState
dismissEncryptedUploadError() dismissEncryptedUploadError()
if (uploadState == null || context == null) return if (room == null || uploadState == null || context == null) return
uploadState.encryptFiles = false uploadState.encryptFiles = false
@@ -521,7 +546,6 @@ class ChatNewMessageViewModel :
} }
RetryMode.SEND -> { RetryMode.SEND -> {
if (room == null) return@launchSigner
ChatFileUploader(account).justUploadNIP17Unencrypted( ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState, uploadState,
onError ?: accountViewModel.toastManager::toast, onError ?: accountViewModel.toastManager::toast,
@@ -538,7 +562,7 @@ class ChatNewMessageViewModel :
} }
private suspend fun innerSendPost(draftTag: String?) { private suspend fun innerSendPost(draftTag: String?) {
val room = room ?: return val room = room.value ?: return
val urls = findURLs(message.text) val urls = findURLs(message.text)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -551,70 +575,49 @@ class ChatNewMessageViewModel :
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() 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 = val template =
if (replyHint == null) { if (replyHint == null) {
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
hashtags(findHashtags(message)) hashtags(findHashtags(message))
references(findURLs(message)) references(findURLs(message))
quotes(findNostrEventUris(message)) quotes(findNostrEventUris(message))
geoHash?.let { geohash(it) } geoHash?.let { geohash(it) }
localZapRaiserAmount?.let { zapraiser(it) } localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) } zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(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,
) {
localExpirationDate?.let { expiration(it) } localExpirationDate?.let { expiration(it) }
}
if (draftTag != null) { emojis(emojis)
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template) imetas(usedAttachments)
}
} else { } 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) { if (draftTag == null) {
ChatFileSender(room, accountViewModel.account).sendAll(uploadsWaitingToBeSent) ChatFileSender(room, accountViewModel.account).sendNIP17(uploadsWaitingToBeSent)
} }
} }
@@ -709,12 +712,10 @@ class ChatNewMessageViewModel :
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
if (users.isNullOrEmpty()) { if (users.isNullOrEmpty()) {
room = null room.emit(null)
updateNIP17StatusFromRoom()
} else { } else {
if (users != room?.users) { if (users != room.value?.users) {
room = ChatroomKey(users) room.emit(ChatroomKey(users))
updateNIP17StatusFromRoom()
} }
} }
} }
@@ -747,9 +748,6 @@ class ChatNewMessageViewModel :
val lastWord = toUsers.currentWord() val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
updateRoomFromUsersInput() updateRoomFromUsersInput()
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm()
nip17 = relayList != null
} }
userSuggestionsMainMessage = null userSuggestionsMainMessage = null
@@ -793,7 +791,8 @@ class ChatNewMessageViewModel :
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) && (!wantsZapraiser || zapRaiserAmount.value != null) &&
(toUsers.text.isNotBlank()) && (toUsers.text.isNotBlank()) &&
uploadState?.multiOrchestrator == null uploadState?.multiOrchestrator == null &&
recipientsMissingDmRelays.value.isEmpty()
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message = message.insertUrlAtCursor(newElement)
@@ -805,15 +804,7 @@ class ChatNewMessageViewModel :
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
} }
fun toggleNIP04And24() { // NIP-04 sending is deprecated. NIP-17 is always used.
if (requiresNIP17) {
nip17 = true
} else {
nip17 = !nip17
}
draftTag.newVersion()
}
override fun updateZapPercentage( override fun updateZapPercentage(
index: Int, index: Int,
@@ -197,7 +197,7 @@ fun NewGroupDMScreen(
// function when the postViewModel is released // function when the postViewModel is released
accountViewModel.launchSigner { accountViewModel.launchSigner {
postViewModel.sendPostSync() postViewModel.sendPostSync()
postViewModel.room?.let { postViewModel.room.value?.let {
nav.nav(routeToMessage(it, null, null, null, null, accountViewModel)) 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) BottomRowActions(postViewModel, accountViewModel)
} }
} }
@@ -393,7 +398,9 @@ private fun BottomRowActions(
.height(50.dp), .height(50.dp),
verticalAlignment = CenterVertically, verticalAlignment = CenterVertically,
) { ) {
if (postViewModel.room != null) { val room by postViewModel.room.collectAsStateWithLifecycle()
if (room != null) {
SelectFromGallery( SelectFromGallery(
isUploading = postViewModel.isUploadingImage, isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile, enabled = !postViewModel.isUploadingFile,
@@ -424,7 +431,7 @@ private fun BottomRowActions(
} }
} }
if (postViewModel.room != null) { if (room != null) {
TakePictureButton( TakePictureButton(
onPictureTaken = { postViewModel.pickedMedia(it) }, onPictureTaken = { postViewModel.pickedMedia(it) },
) )
@@ -443,7 +450,7 @@ private fun BottomRowActions(
} }
} }
if (postViewModel.room != null) { if (room != null) {
TakeVideoButton( TakeVideoButton(
onVideoTaken = { postViewModel.pickedMedia(it) }, onVideoTaken = { postViewModel.pickedMedia(it) },
) )
@@ -550,8 +557,6 @@ fun SendDirectMessageTo(
focusedBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent,
), ),
) )
ToggleNip17Button(postViewModel, accountViewModel)
} }
HorizontalDivider(thickness = DividerThickness) HorizontalDivider(thickness = DividerThickness)
@@ -21,34 +21,50 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList 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.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog
@@ -61,9 +77,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.PostKeyboard 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.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Preview @Preview
@Composable @Composable
@@ -166,7 +186,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)
}
} }
} }
@@ -211,6 +236,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 @Composable
fun KeyboardLeadingIcon( fun KeyboardLeadingIcon(
channelScreenModel: ChatNewMessageViewModel, channelScreenModel: ChatNewMessageViewModel,
@@ -218,7 +327,7 @@ fun KeyboardLeadingIcon(
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 10.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) { ) {
SelectFromGallery( SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage, isUploading = channelScreenModel.isUploadingImage,
@@ -226,8 +335,6 @@ fun KeyboardLeadingIcon(
modifier = Modifier, modifier = Modifier,
onImageChosen = channelScreenModel::pickedMedia, onImageChosen = channelScreenModel::pickedMedia,
) )
ToggleNip17Button(channelScreenModel, accountViewModel)
} }
} }
@@ -25,94 +25,25 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.painterRes 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier import com.vitorpamplona.amethyst.ui.theme.IncognitoIconButtonModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
@Composable @Composable
fun ToggleNip17Button( fun Nip17Indicator(channelScreenModel: ChatNewMessageViewModel) {
channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
) {
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
if (wantsToActivateNIP17) {
NewFeatureNIP17AlertDialog(
accountViewModel = accountViewModel,
onConfirm = { channelScreenModel.toggleNIP04And24() },
onDismiss = { wantsToActivateNIP17 = false },
)
}
IconButton( IconButton(
modifier = Modifier.width(30.dp), modifier = Modifier.width(30.dp),
onClick = { onClick = { },
if ( enabled = false,
!accountViewModel.account.settings.hideNIP17WarningDialog &&
!channelScreenModel.nip17 &&
!channelScreenModel.requiresNIP17
) {
wantsToActivateNIP17 = true
} else {
channelScreenModel.toggleNIP04And24()
}
},
) { ) {
if (channelScreenModel.nip17) { Icon(
Icon( painter = painterRes(R.drawable.incognito, 2),
painter = painterRes(R.drawable.incognito, 2), contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message),
contentDescription = stringRes(id = R.string.accessibility_turn_off_sealed_message), modifier = IncognitoIconButtonModifier,
modifier = IncognitoIconButtonModifier, tint = MaterialTheme.colorScheme.primary,
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,
)
}
} }
} }
@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.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.quartz.nip01Core.tags.references.references 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.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -100,47 +99,7 @@ class ChatFileSender(
account.sendNip17PrivateMessage(template) 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>) { suspend fun sendAll(uploads: List<SuccessfulUploads>) {
uploads.forEach { sendNIP17(uploads)
if (it.cipher != null) {
sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher)
} else {
sendNIP04(it.result, it.caption, it.contentWarningReason)
}
}
} }
} }
@@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -46,7 +47,8 @@ fun RoomChatFileUploadDialog(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val room = channelScreenModel.room ?: return val roomState = channelScreenModel.room.collectAsStateWithLifecycle()
val room = roomState.value ?: return
val context = LocalContext.current val context = LocalContext.current
ChatFileUploadDialog( ChatFileUploadDialog(
@@ -75,6 +77,6 @@ fun RoomChatFileUploadDialog(
onCancel, onCancel,
accountViewModel, accountViewModel,
nav, nav,
isNip17 = channelScreenModel.nip17, isNip17 = true,
) )
} }
@@ -1532,8 +1532,20 @@
<string name="following_accounts">Obserwujesz %1$d kont(a)…</string> <string name="following_accounts">Obserwujesz %1$d kont(a)…</string>
<string name="import_follows_tips">"Wprowadź profil znajomego lub lidera społeczności. Możesz użyć ich npub-u, adresu NIP-05 lub nazwy jak alicja@domena.pl lub id/alicja dla zweryfikowanych przez blockchain tożsamości."</string> <string name="import_follows_tips">"Wprowadź profil znajomego lub lidera społeczności. Możesz użyć ich npub-u, adresu NIP-05 lub nazwy jak alicja@domena.pl lub id/alicja dla zweryfikowanych przez blockchain tożsamości."</string>
<string name="select_all">Wybierz Wszystkie</string> <string name="select_all">Wybierz Wszystkie</string>
<string name="event_sync_what_happens_body">To narzędzie skanuje wszystkie transmitery, z którymi zetknęła się Twoja aplikacja, i przekierowuje zdarzenia do właściwych miejsc docelowych: </string>
<string name="event_sync_step1">Pobierz wszystkie wydarzenia, których jesteś autorem, i wyślij je do transmiterów nadawczych.</string>
<string name="event_sync_step2">Pobierz wszystkie wydarzenia, w których jesteś wymieniony, i prześlij je do swoich transmiterów odbiorczych.</string>
<string name="event_sync_step3">Pobierz wszystkie bezpośrednie wiadomości adresowane do Ciebie i wyślij je do swoich transmiterów DM.</string>
<string name="event_sync_wifi_warning">⚠️ Wygląda na to, że korzystasz z połączenia komórkowego lub z limitem transferu danych. Ta operacja może wymagać przesłania bardzo dużej ilości danych. Przed rozpoczęciem podłącz się do sieci Wi-Fi.</string>
<string name="event_sync_mobile_data_dialog_title">Włączyć transmisje danych?</string>
<string name="event_sync_pause">Pauza</string>
<string name="event_sync_resume">Wznów</string>
<string name="event_sync_start_over">Zacznij od początku</string>
<string name="event_sync_cancel">Anuluj</string>
<string name="event_sync_relays_progress">Transmitery: %1$d / %2$d</string>
<string name="events">wydarzenia</string> <string name="events">wydarzenia</string>
<string name="profiles">profile</string> <string name="profiles">profile</string>
<string name="relay_settings_lower">ustawienia transmiterów</string> <string name="relay_settings_lower">ustawienia transmiterów</string>
<string name="last_seen">Ostatnio widziano %1$s temu</string> <string name="last_seen">Ostatnio widziano %1$s temu</string>
<string name="event_sync_status_error">Błąd</string>
</resources> </resources>
@@ -1328,4 +1328,202 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="badge_and_n_others"> in %1$d ostalih</string> <string name="badge_and_n_others"> in %1$d ostalih</string>
<string name="remove_language">Odstrani %1$s</string> <string name="remove_language">Odstrani %1$s</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<string name="kind_audio_header">Zvočna glava</string>
<string name="kind_audio_track">Zvočni zapis</string>
<string name="kind_badge_awards">Nagradne značke</string>
<string name="kind_badge_definitions">Definicija značke</string>
<string name="kind_profile_badges">Značke profila</string>
<string name="kind_blocked_relays">Blokirani releji</string>
<string name="kind_blossom_servers">Blossom strežniki</string>
<string name="kind_blossom_auth">Blossom avtentikacija</string>
<string name="kind_broadcast_relays">Oddajni releji</string>
<string name="kind_bookmark_list">Seznam zaznamkov</string>
<string name="kind_day_appointment">Dnevni termin</string>
<string name="kind_calendar">Koledar</string>
<string name="kind_appointment">Sestanek</string>
<string name="kind_appt_rsvp">Potrditev termina</string>
<string name="kind_chess_games">Šahovske igre</string>
<string name="kind_chess_auth">Šahovska avtentikacija</string>
<string name="kind_favorite_relays">Priljubljeni releji</string>
<string name="kind_chess_challenges">Šahovski izzivi</string>
<string name="kind_chess_game_accept">Sprejmi partijo</string>
<string name="kind_chess_move">Šahovska poteza</string>
<string name="kind_chess_game_end">Konec partije</string>
<string name="kind_chess_draw_offer">Predlagaj remi</string>
<string name="kind_channel_definition">Definicija kanala</string>
<string name="kind_channel_hide_msg">Skrij sporočilo v kanalu</string>
<string name="kind_channel_list">Seznam kanalov</string>
<string name="kind_channel_message">Sporočilo v kanalu</string>
<string name="kind_channel_metadata">Metapodatki kanala</string>
<string name="kind_channel_mute_user">Utišani uporabniki kanala</string>
<string name="kind_dm_file">Datoteka ZS</string>
<string name="kind_dm_message">ZS sporočilo</string>
<string name="kind_dm_relays">Releji ZS</string>
<string name="kind_classifieds">Oglasi</string>
<string name="kind_comments">Komentarji</string>
<string name="kind_community_def">Opis skupnosti</string>
<string name="kind_community_list">Seznam skupnosti</string>
<string name="kind_community_post">Objava skupnosti</string>
<string name="kind_follow_list">Seznam sledenih</string>
<string name="kind_deletions">Izbrisano</string>
<string name="kind_drafts">Osnutki</string>
<string name="kind_emoji_packs">Zbirke emojijev</string>
<string name="kind_emoji_pack_list">Pregled zbirk emojijev</string>
<string name="kind_ephemeral_chat">Minljivi klepet</string>
<string name="kind_ephemeral_chatrooms">Minljive klepetalnice</string>
<string name="kind_file_headers">Glave datotek</string>
<string name="kind_profile_gallery">Galerija profila</string>
<string name="kind_file_servers">Datotečni strežniki</string>
<string name="kind_blob_data">Blob podatki</string>
<string name="kind_blob_headers">Blob glava</string>
<string name="kind_medical_data">Zdravstveni podatki</string>
<string name="kind_follow_packs">Paketi za sledenje</string>
<string name="kind_reposts_16">Ponovno preposlani (16)</string>
<string name="kind_geohash_follows">Sledeni geohash-i</string>
<string name="kind_gift_wraps">GiftWraps</string>
<string name="kind_git_issue">Git zadeva</string>
<string name="kind_git_patch">Git popravek</string>
<string name="kind_git_repo">Git repozitorij</string>
<string name="kind_git_reply">Git odgovor</string>
<string name="kind_zap_goals">Ciljni znesek zapov</string>
<string name="kind_hashtag_follows">Sledeni ključniki</string>
<string name="kind_highlights">Osvetlitve</string>
<string name="kind_http_auth">Http Auth</string>
<string name="kind_index_relay_list">Indeksiraj seznam relejev</string>
<string name="kind_adventure_prologue">Pustolovski prolog</string>
<string name="kind_adventure_scene">Pustolovska scena</string>
<string name="kind_adventure_reading">Pustolovsko branje</string>
<string name="kind_named_bookmarks">Poimenovani zaznamki</string>
<string name="kind_live_chats">Pogovori v živo</string>
<string name="kind_live_streams">Prenosi v živo</string>
<string name="kind_zaps">Zapi</string>
<string name="kind_nwc_request">NWC zahtevek</string>
<string name="kind_nwc_response">NWC odgovor</string>
<string name="kind_private_zaps">Zasebni zapi</string>
<string name="kind_zap_req">Zap zahtevek</string>
<string name="kind_blogs">Blogi</string>
<string name="kind_meeting_room">Sejna soba</string>
<string name="kind_room_presence">Prisotnost v sobi</string>
<string name="kind_meeting_space">Srečevalnica</string>
<string name="kind_profile">Profil</string>
<string name="kind_mute_list">Seznam utišanih</string>
<string name="kind_nns">NNS</string>
<string name="kind_nip">NIP</string>
<string name="kind_nostr_connect">Nostr Connect</string>
<string name="kind_dvm_status">DVM status</string>
<string name="kind_dvm_content_req">DVM zahtevek vsebine</string>
<string name="kind_dvm_content_resp">DVM odgovor na vsebino</string>
<string name="kind_dvm_user_req">DVM zahtevek uporabnika</string>
<string name="kind_dvm_user_resp">DVM odgovor uporabnika</string>
<string name="kind_ots">OTS</string>
<string name="kind_pay_to">Plačaj</string>
<string name="kind_people_lists">Seznam oseb</string>
<string name="kind_pictures">Fotografije</string>
<string name="kind_pins">Bucike</string>
<string name="kind_zap_poll">Zap sklad</string>
<string name="kind_poll">Glasovanje</string>
<string name="kind_poll_response">Odziv na glasovanje</string>
<string name="kind_nip04_dms">NIP-04 ZS</string>
<string name="kind_private_relays">Zasebni releji</string>
<string name="kind_proxy_relays">Proxy releji</string>
<string name="kind_public_message">Javno sporočilo</string>
<string name="kind_reactions">Odzivne ikone</string>
<string name="kind_contact_card">Kartica kontakta</string>
<string name="kind_relay_auth">Autorizacija releja</string>
<string name="kind_relay_discovery">Odkrivanje relejev</string>
<string name="kind_relay_monitor">Obvestilo nadzornika relejev</string>
<string name="kind_relay_set">Nabor relejev</string>
<string name="kind_reports">Prijave</string>
<string name="kind_reposts">Ponovne objave</string>
<string name="kind_user_delete">Brisanje uporabnika</string>
<string name="kind_seals">Pečat</string>
<string name="kind_search_relays">Iskalni releji</string>
<string name="kind_user_status">Status uporabnika</string>
<string name="kind_notes">Zapiski</string>
<string name="kind_edits">Urejeni</string>
<string name="kind_torrents">Torenti</string>
<string name="kind_torrent_comments">Komentarji torentov</string>
<string name="kind_trusted_relays">Zaupanja vredni releji</string>
<string name="kind_trusted_providers">Zaupanja vredni ponudniki</string>
<string name="kind_video_repl">Videoposnetki (odgovor)</string>
<string name="kind_shorts_repl">Kratki posnetki (odgovor)</string>
<string name="kind_video">Videoposnetki</string>
<string name="kind_shorts">Kratki videoposnetki</string>
<string name="kind_voice_msg">Zvočno sporočilo</string>
<string name="kind_voice_reply">Zvočni odgovori</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate.</string>
<string name="import_follow_list">Uvozi seznam sledenih</string>
<string name="select_users_to_follow">Izberite uporabnike, ki jim želite slediti</string>
<string name="profile_to_import_from">Profil, iz katerega želite uvoziti</string>
<string name="name_search_npub1_alice_example_com">Iskanje, npub1…, janez@primer.si</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Podpira npub, nprofile, NIP-05, hex in namecoin (.bit, d/, id/)</string>
<string name="look_up_follow_list">Poišči seznam sledenih</string>
<string name="tip">Daj napitnino</string>
<string name="accounts_found">Najdeno %1$d računov</string>
<string name="num_selected">Izbranih %1$d</string>
<string name="resolved_via_namecoin">Razrešeno prek Namecoina</string>
<string name="now_following_accounts">Zdaj sledite %1$d računom</string>
<string name="your_feed_is_ready">Vaš vsebina objav je pripravljen.</string>
<string name="skip">Preskoči</string>
<string name="search_another">Poišči še koga</string>
<string name="follow_accounts">Sledi %1$d računom</string>
<string name="import_more">Dodaten uvoz</string>
<string name="continue_txt">Nadaljuj</string>
<string name="skip_for_now">Preskoči za zdaj</string>
<string name="resolving">Razreševanje %1$s…</string>
<string name="fetching_follow_list">Pridobivanje seznama sledenih…</string>
<string name="no_follows_found">Ni najdenih sledilcev</string>
<string name="following_accounts">Sledenje %1$d računom…</string>
<string name="import_follows_tips">"Vnesite profil prijatelja ali vodje skupnosti. Uporabite lahko njihov npub, naslov NIP-05 ali ime Namecoin (npr. janez@primer.bit ali id/janez) za identitete, preverjene prek verige blokov."</string>
<string name="select_all">Izberi vse</string>
<string name="uptime">%1$d%% časa delovanja</string>
<string name="namecoin_settings">Namecoin nastavitve</string>
<string name="event_sync_title">Sinhronizacija relejev</string>
<string name="event_sync_section">Sinhronizacija relejev</string>
<string name="event_sync_section_explainer">Ponovno objavite svoje dogodke na vseh znanih relejih, da posodobite svoje izhodne, vhodne in releje ZS. Zahtevana je povezava Wi-Fi — postopek lahko porabi veliko podatkov.</string>
<string name="event_sync_open_button">Odpri sinhronizacijo relejev…</string>
<string name="event_sync_what_happens_title">Kaj to naredi</string>
<string name="event_sync_what_happens_body">To orodje pregleda vse releje, ki jih je zaznala vaša aplikacija, in vaše dogodke prerazporedi na ustrezne cilje:</string>
<string name="event_sync_step1">Prenesite vse dogodke katere ste ustvarili, in jih pošljite v vaše izhodne releje.</string>
<string name="event_sync_step2">Prenesite vse dogodke, v katerih ste omenjeni, in jih pošljite v vaše vhodne releje.</string>
<string name="event_sync_step3">Prenesite vsa zasebna sporočila, naslovljena na vas, in jih pošljite v svoje releje za zasebna sporočila.</string>
<string name="event_sync_wifi_warning">⚠ Kaže, da uporabljate omejeno ali mobilno povezavo. Ta postopek lahko prenese zelo veliko količino podatkov. Pred začetkom se povežite v omrežje Wi-Fi.</string>
<string name="event_sync_mobile_data_dialog_title">Uporabi mobilne podatke?</string>
<string name="event_sync_start">Začni sinhronizacijo</string>
<string name="event_sync_start_anyway">Vseeno začni (mobilni podatki)</string>
<string name="event_sync_pause">Premor</string>
<string name="event_sync_resume">Nadaljuj</string>
<string name="event_sync_start_over">Začni znova</string>
<string name="event_sync_cancel">Prekini</string>
<string name="event_sync_relays_progress">Releji: %1$d / %2$d</string>
<string name="event_sync_events_sent">Prerazporejeni dogodki: %1$d novih od %2$d poslanih in %3$d prejetih</string>
<string name="event_sync_paused_title">Premor sinhronizacije</string>
<string name="event_sync_paused_body">Zaključenih %1$d od %2$d relejev — do zdaj prerazporejenih %3$d dogodkov. Dotaknite se »Nadaljuj« za nadaljevanje.</string>
<string name="event_sync_done_title">Sinhronizacija zaključena</string>
<string name="event_sync_done_sent">Naprej posredovanih %1$d od %2$d prejetih dogodkov do ciljnih relejev.</string>
<string name="event_sync_done_accepted">Število dogodkov, ki jih je ciljni rele sprejel kot nove: %1$d.</string>
<string name="event_sync_done_duration">Zaključeno v %1$d sekundah</string>
<string name="event_sync_error_title">Napaka pri sinhronizaciji</string>
<string name="event_sync_sending_to">Pošiljam k</string>
<string name="event_sync_outbox_relays">Odhodni</string>
<string name="event_sync_inbox_relays">Vhodni</string>
<string name="event_sync_dm_relays">ZS</string>
<string name="event_sync_activity_log">Trenutno preverjam (%1$d relejev)</string>
<string name="event_sync_activity_log_finished">Finished (%1$d relays)</string>
<string name="event_sync_log_sent">poslano %1$s</string>
<string name="event_sync_log_recv">sprejeto %1$s</string>
<string name="event_sync_log_new">nov %1$s</string>
<string name="event_sync_no_events">ni dogodkov</string>
<string name="ots_explorer_settings">Bitcoin Raziskovalec (OTS)</string>
<string name="events">dogodki</string>
<string name="dms">ZS</string>
<string name="profiles">profili</string>
<string name="relay_settings_lower">Nastavitve releja</string>
<string name="last_seen">Nazadnje viden pred %1$s </string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Povezujem</string>
<string name="event_sync_status_downloading">Prenašam</string>
<string name="event_sync_status_error">Napaka</string>
<string name="event_sync_status_completed">Zaključeno</string>
</resources> </resources>
+1
View File
@@ -1359,6 +1359,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_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_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="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_title">Search Relays</string>
<string name="search_relays_not_found">Set up your 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.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references 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.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey 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.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
@@ -62,9 +60,6 @@ class ChatNewMessageState(
private val _message = MutableStateFlow(TextFieldValue("")) private val _message = MutableStateFlow(TextFieldValue(""))
val message: StateFlow<TextFieldValue> = _message.asStateFlow() val message: StateFlow<TextFieldValue> = _message.asStateFlow()
private val _nip17 = MutableStateFlow(false)
val nip17: StateFlow<Boolean> = _nip17.asStateFlow()
private val _replyTo = MutableStateFlow<Note?>(null) private val _replyTo = MutableStateFlow<Note?>(null)
val replyTo: StateFlow<Note?> = _replyTo.asStateFlow() val replyTo: StateFlow<Note?> = _replyTo.asStateFlow()
@@ -74,38 +69,37 @@ class ChatNewMessageState(
private val _room = MutableStateFlow<ChatroomKey?>(null) private val _room = MutableStateFlow<ChatroomKey?>(null)
val room: StateFlow<ChatroomKey?> = _room.asStateFlow() val room: StateFlow<ChatroomKey?> = _room.asStateFlow()
/** Whether NIP-17 is required (group chat with >1 recipient) */ /** Whether any recipients are missing DM relay lists, preventing message delivery */
private val _requiresNip17 = MutableStateFlow(false) private val _recipientsMissingDmRelays = MutableStateFlow(false)
val requiresNip17: StateFlow<Boolean> = _requiresNip17.asStateFlow() 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 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, * Load a chatroom. Sets the room key and checks recipient DM relay availability.
* and auto-detects NIP-17 requirement (group chats require NIP-17).
*/ */
fun load(roomKey: ChatroomKey) { fun load(roomKey: ChatroomKey) {
_room.value = roomKey _room.value = roomKey
updateNip17FromRoom() updateRecipientRelayStatus()
} }
/** /**
* Auto-detect NIP-17 based on room: * Check if all recipients have DM relay lists.
* - Group chats (>1 recipient) always require NIP-17 * Messages can only be sent via NIP-17, so recipients must have
* - Single recipient: NIP-17 off by default (can be toggled) * either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
*/ */
fun updateNip17FromRoom() { fun updateRecipientRelayStatus() {
val currentRoom = _room.value val currentRoom = _room.value
if (currentRoom != null) { if (currentRoom != null) {
_requiresNip17.value = currentRoom.users.size > 1 _recipientsMissingDmRelays.value =
if (_requiresNip17.value) { currentRoom.users.any { hexKey ->
_nip17.value = true val user = cache.getOrCreateUser(hexKey) as? User
} user?.dmInboxRelays().isNullOrEmpty()
}
} else { } else {
_requiresNip17.value = false _recipientsMissingDmRelays.value = false
_nip17.value = false
} }
} }
@@ -126,27 +120,7 @@ class ChatNewMessageState(
} }
/** /**
* Toggle NIP-04/NIP-17 mode. * Send the current message as NIP-17. NIP-04 is deprecated for sending.
* 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.
* *
* @return true if send was initiated, false if preconditions not met * @return true if send was initiated, false if preconditions not met
*/ */
@@ -154,12 +128,9 @@ class ChatNewMessageState(
val currentRoom = _room.value ?: return false val currentRoom = _room.value ?: return false
val messageText = _message.value.text val messageText = _message.value.text
if (messageText.isBlank()) return false 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)
sendNip17(currentRoom, messageText)
} else {
sendNip04(currentRoom, messageText)
}
return true return true
} }
@@ -193,23 +164,6 @@ class ChatNewMessageState(
account.sendNip17PrivateMessage(template) 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. * Clear all composition state after sending or cancelling.
*/ */
@@ -150,25 +150,18 @@ fun ChatPane(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val feedState by feedViewModel.feedState.feedContent.collectAsState() val feedState by feedViewModel.feedState.feedContent.collectAsState()
val messageText by messageState.message.collectAsState() val messageText by messageState.message.collectAsState()
val isNip17 by messageState.nip17.collectAsState() val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState()
val requiresNip17 by messageState.requiresNip17.collectAsState()
// File attachment state // File attachment state
val attachedFiles = remember { mutableStateListOf<File>() } val attachedFiles = remember { mutableStateListOf<File>() }
var isUploading by remember { mutableStateOf(false) } var isUploading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Helper: attach files and auto-force NIP-17 if needed // Helper: attach files
fun attachFiles(files: List<File>) { fun attachFiles(files: List<File>) {
val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS } val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }
if (mediaFiles.isEmpty()) return if (mediaFiles.isEmpty()) return
attachedFiles.addAll(mediaFiles) 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) // Drag-and-drop target for file attachments (NIP-17 only)
@@ -346,13 +339,11 @@ fun ChatPane(
// Message input // Message input
MessageInput( MessageInput(
messageText = messageText.text, messageText = messageText.text,
isNip17 = isNip17, recipientsMissingRelays = recipientsMissingRelays,
requiresNip17 = requiresNip17,
canSend = messageState.canSend || attachedFiles.isNotEmpty(), canSend = messageState.canSend || attachedFiles.isNotEmpty(),
isUploading = isUploading, isUploading = isUploading,
hasAttachments = attachedFiles.isNotEmpty(), hasAttachments = attachedFiles.isNotEmpty(),
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
onToggleNip17 = { messageState.toggleNip17() },
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) }, onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
onSend = { onSend = {
scope.launch { scope.launch {
@@ -698,13 +689,11 @@ private fun ReactionBar(onReaction: (String) -> Unit) {
@Composable @Composable
private fun MessageInput( private fun MessageInput(
messageText: String, messageText: String,
isNip17: Boolean, recipientsMissingRelays: Boolean,
requiresNip17: Boolean,
canSend: Boolean, canSend: Boolean,
isUploading: Boolean = false, isUploading: Boolean = false,
hasAttachments: Boolean = false, hasAttachments: Boolean = false,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onToggleNip17: () -> Unit,
onAttach: () -> Unit = {}, onAttach: () -> Unit = {},
onSend: () -> Unit, onSend: () -> Unit,
) { ) {
@@ -773,46 +762,30 @@ private fun MessageInput(
} }
} }
// NIP-17 indicator // NIP-17 indicator / recipient warning
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
) { ) {
IconButton( Icon(
onClick = onToggleNip17, imageVector = Icons.Default.Lock,
enabled = !requiresNip17, contentDescription = "NIP-17 (encrypted)",
modifier = Modifier.size(20.dp), modifier = Modifier.size(16.dp),
) { tint = MaterialTheme.colorScheme.primary,
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)
},
)
}
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text( Text(
text = if (isNip17) "NIP-17" else "NIP-04", text = "NIP-17",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color = MaterialTheme.colorScheme.primary,
if (isNip17) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
) )
if (requiresNip17) { if (recipientsMissingRelays) {
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = "(required for groups)", text = "Recipient has no DM relay list — messages cannot be delivered",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.error,
) )
} }
} }