diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 8ae6448bc..44d7f8c75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -403,7 +403,16 @@ class ChatNewMessageViewModel : accountViewModel.launchSigner { if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.HOLD + }, + context, + ) { uploadsWaitingToBeSent += it draftTag.newVersion() onceUploaded() @@ -428,7 +437,19 @@ class ChatNewMessageViewModel : accountViewModel.launchSigner { if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + 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() @@ -443,6 +464,69 @@ class ChatNewMessageViewModel : } } + // Encrypted upload error state for retry dialog + var encryptedUploadErrorTitle by mutableStateOf(null) + var encryptedUploadErrorMessage by mutableStateOf(null) + var pendingRetryMode by mutableStateOf(null) + var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null) + var pendingRetryContext by mutableStateOf(null) + var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null) + + enum class RetryMode { HOLD, SEND } + + fun dismissEncryptedUploadError() { + encryptedUploadErrorTitle = null + encryptedUploadErrorMessage = null + pendingRetryMode = null + pendingRetryOnError = null + pendingRetryContext = null + pendingRetryOnceUploaded = null + } + + fun retryWithoutEncryption() { + val mode = pendingRetryMode ?: return + val onError = pendingRetryOnError + val context = pendingRetryContext + val onceUploaded = pendingRetryOnceUploaded + val room = room + val uploadState = uploadState + + dismissEncryptedUploadError() + + if (uploadState == null || context == null) return + + uploadState.encryptFiles = false + + accountViewModel.launchSigner { + when (mode) { + RetryMode.HOLD -> { + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + ) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded?.invoke() + } + } + + RetryMode.SEND -> { + if (room == null) return@launchSigner + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + ) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded?.invoke() + } + } + } + } + } + private suspend fun innerSendPost(draftTag: String?) { val room = room ?: return @@ -563,6 +647,8 @@ class ChatNewMessageViewModel : uploadsWaitingToBeSent = emptyList() uploadState?.reset() + dismissEncryptedUploadError() + iMetaAttachments.reset() emojiSuggestions?.reset() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index c8f1bf6c9..117f96d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -302,6 +302,15 @@ fun GroupDMScreenContent( ) } } + + postViewModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = postViewModel.encryptedUploadErrorMessage ?: "", + onDismiss = postViewModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = postViewModel::retryWithoutEncryption, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 18df26d77..b5b38901d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -27,8 +27,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +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.ui.Alignment @@ -116,6 +118,15 @@ fun PrivateMessageEditFieldRow( } } + channelScreenModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = channelScreenModel.encryptedUploadErrorMessage ?: "", + onDismiss = channelScreenModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption, + ) + } + Column( modifier = EditFieldModifier, ) { @@ -217,3 +228,36 @@ fun KeyboardLeadingIcon( ToggleNip17Button(channelScreenModel, accountViewModel) } } + +@Composable +fun EncryptedUploadErrorDialog( + title: String, + message: String, + onDismiss: () -> Unit, + onRetryWithoutEncryption: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(message) + Text( + stringRes(R.string.upload_without_encryption_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + }, + confirmButton = { + TextButton(onClick = onRetryWithoutEncryption) { + Text(stringRes(R.string.retry_without_encryption)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 7ec7bd245..9164a505b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -24,11 +24,14 @@ import com.vitorpamplona.amethyst.model.Account 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 import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.ciphers.AESGCM class ChatFileSender( @@ -39,6 +42,8 @@ class ChatFileSender( uploads.forEach { if (it.cipher != null) { sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) + } else { + sendNIP17AsHiddenLink(it.result, it.caption, it.contentWarningReason) } } } @@ -70,6 +75,31 @@ class ChatFileSender( ) } + suspend fun sendNIP17AsHiddenLink( + result: UploadOrchestrator.OrchestratorResult.ServerResult, + caption: String?, + contentWarningReason: String?, + ) { + val iMetaAttachments = IMetaAttachments() + iMetaAttachments.add(result, caption, contentWarningReason) + + val toUsers = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() } + + val template = + ChatMessageEvent.build(result.url, toUsers) { + references(listOf(result.url)) + + if (!caption.isNullOrEmpty()) { + alt(caption) + } + contentWarningReason?.let { contentWarning(it) } + + imetas(iMetaAttachments.filterIsIn(setOf(result.url))) + } + + account.sendNip17PrivateMessage(template) + } + // ------ // NIP 04 // ------ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 1dca30b9f..d5c06cfcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog( onCancel, accountViewModel, nav, + isNip17 = channelScreenModel.nip17, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index 33a9e456e..2770d85f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -39,20 +39,68 @@ class ChatFileUploader( suspend fun justUploadNIP17( viewState: ChatFileUploadState, onError: (title: String, message: String) -> Unit, + onEncryptedUploadError: (title: String, message: String) -> Unit, context: Context, onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) - val cipher = AESGCM() + if (viewState.encryptFiles) { + val cipher = AESGCM() + + val results = + orchestrator.uploadEncrypted( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + cipher, + viewState.selectedServer, + account, + context, + ) + + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) + } else { + null + } + } + + onceUploaded(list) + viewState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onEncryptedUploadError( + stringRes(context, R.string.failed_to_upload_encrypted_media_title), + stringRes(context, R.string.failed_to_upload_encrypted_media_message) + "\n\n" + errorMessages.joinToString(".\n"), + ) + } + } else { + justUploadNIP17Unencrypted(viewState, onError, context, onceUploaded) + } + + viewState.mediaUploadTracker.finishUpload() + } + + suspend fun justUploadNIP17Unencrypted( + viewState: ChatFileUploadState, + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: suspend (List) -> Unit, + ) { + val orchestrator = viewState.multiOrchestrator ?: return + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) val results = - orchestrator.uploadEncrypted( + orchestrator.upload( viewState.caption, viewState.contentWarningReason, MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), - cipher, viewState.selectedServer, account, context, @@ -62,7 +110,7 @@ class ChatFileUploader( val list = results.successful.mapNotNull { state -> if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null) } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index f1ce97525..c05fa0c4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -81,6 +81,7 @@ fun ChatFileUploadDialog( onCancel: () -> Unit, accountViewModel: AccountViewModel, nav: INav, + isNip17: Boolean = false, ) { val scrollState = rememberScrollState() @@ -132,7 +133,7 @@ fun ChatFileUploadDialog( ) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { - ImageVideoPostChat(state, accountViewModel) + ImageVideoPostChat(state, accountViewModel, isNip17) } } } @@ -144,6 +145,7 @@ fun ChatFileUploadDialog( private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, + isNip17: Boolean = false, ) { val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() @@ -190,6 +192,16 @@ private fun ImageVideoPostChat( onCheckedChange = fileUploadState::updateContentWarning, ) + if (isNip17) { + SettingSwitchItem( + title = R.string.encrypt_files_label, + description = R.string.encrypt_files_description, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + checked = fileUploadState.encryptFiles, + onCheckedChange = { fileUploadState.encryptFiles = it }, + ) + } + SettingsRow(R.string.file_server, R.string.file_server_description) { TextSpinner( label = "", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 32105a29c..1e6717ae0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -55,6 +55,8 @@ class ChatFileUploadState( // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED var mediaQualitySlider by mutableIntStateOf(1) + var encryptFiles by mutableStateOf(true) + fun load(uris: ImmutableList) { reset() this.multiOrchestrator = MultiOrchestrator(uris) @@ -70,6 +72,7 @@ class ChatFileUploadState( mediaUploadTracker.finishUpload() caption = "" selectedServer = defaultServer + encryptFiles = true } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 716b4545a..64f529a3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -31,9 +29,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ElevatedButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -55,7 +56,8 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -80,10 +82,67 @@ private fun RenderDraftListScreen( ) { WatchLifecycleAndUpdateModel(feedState) + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { + showDeleteDialog = false + }, + title = { + Text(text = stringResource(R.string.drafts)) + }, + text = { + Text(text = stringResource(R.string.delete_all_drafts_confirmation)) + }, + confirmButton = { + TextButton( + onClick = { + val currentState = feedState.feedContent.value + if (currentState is FeedState.Loaded) { + accountViewModel.delete(currentState.feed.value.list) + } + showDeleteDialog = false + }, + ) { + Text(text = stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton( + onClick = { + showDeleteDialog = false + }, + ) { + Text(text = stringResource(R.string.no)) + } + }, + ) + } + DisappearingScaffold( isInvertedLayout = false, topBar = { - TopBarWithBackButton(stringRes(id = R.string.drafts), nav::popBack) + ShorterTopAppBar( + title = { + Text( + text = stringRes(id = R.string.drafts), + ) + }, + navigationIcon = { + IconButton(onClick = nav::popBack) { + ArrowBackIcon() + } + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete_all), + ) + } + }, + ) }, accountViewModel = accountViewModel, ) { @@ -104,7 +163,6 @@ private fun RenderDraftListScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun DraftFeedLoaded( loaded: FeedState.Loaded, @@ -114,60 +172,12 @@ private fun DraftFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() - var showDeleteDialog by remember { mutableStateOf(false) } - - if (showDeleteDialog) { - AlertDialog( - onDismissRequest = { - showDeleteDialog = false - }, - title = { - Text(text = stringResource(R.string.drafts)) - }, - text = { - Text(text = stringResource(R.string.delete_all_drafts_confirmation)) - }, - confirmButton = { - TextButton( - onClick = { - accountViewModel.delete(items.list) - showDeleteDialog = false - }, - ) { - Text(text = stringResource(R.string.yes)) - } - }, - dismissButton = { - TextButton( - onClick = { - showDeleteDialog = false - }, - ) { - Text(text = stringResource(R.string.no)) - } - }, - ) - } - LazyColumn( contentPadding = FeedPadding, state = listState, ) { - stickyHeader { - Row( - Modifier - .fillMaxWidth(), - Arrangement.Center, - ) { - ElevatedButton( - onClick = { showDeleteDialog = true }, - ) { - Text(stringResource(R.string.delete_all)) - } - } - } itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> - Row(Modifier.fillMaxWidth().animateItem()) { + Row(Modifier.fillMaxWidth()) { SwipeToDeleteWithConfirmation( modifier = Modifier.fillMaxWidth().animateContentSize(), onDelete = { accountViewModel.delete(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index 0f1818809..db4c81307 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDownward @@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -49,6 +51,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -84,8 +87,30 @@ fun WalletTransactionsScreen( walletViewModel.fetchTransactions() } - val transactions by walletViewModel.transactions.collectAsState() + val transactions by walletViewModel.filteredTransactions.collectAsState() val isLoading by walletViewModel.isLoading.collectAsState() + val isLoadingMore by walletViewModel.isLoadingMore.collectAsState() + val hasMore by walletViewModel.hasMoreTransactions.collectAsState() + val currentFilter by walletViewModel.transactionFilter.collectAsState() + + val listState = rememberLazyListState() + + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleIndex >= totalItems - 5 && !isLoadingMore && hasMore && transactions.isNotEmpty() + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + walletViewModel.loadMoreTransactions() + } + } Scaffold( topBar = { @@ -144,16 +169,63 @@ fun WalletTransactionsScreen( } else { LazyColumn( modifier = Modifier.padding(padding), + state = listState, ) { + item { + TransactionFilterRow(currentFilter) { walletViewModel.setTransactionFilter(it) } + } items(transactions) { tx -> TransactionItem(tx, accountViewModel, nav) HorizontalDivider() } + if (isLoadingMore) { + item { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } } } } } +@Composable +private fun TransactionFilterRow( + currentFilter: TransactionFilter, + onFilterSelected: (TransactionFilter) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = currentFilter == TransactionFilter.ALL, + onClick = { onFilterSelected(TransactionFilter.ALL) }, + label = { Text(stringRes(R.string.wallet_filter_all)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.ZAPS, + onClick = { onFilterSelected(TransactionFilter.ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_zaps)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.NON_ZAPS, + onClick = { onFilterSelected(TransactionFilter.NON_ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_non_zaps)) }, + ) + } +} + @Composable private fun TransactionItem( tx: NwcTransaction, @@ -317,7 +389,7 @@ private fun TransactionUserName( ) } else { Text( - text = fallbackName ?: pubkeyHex.take(8) + "...", + text = fallbackName ?: (pubkeyHex.take(8) + "..."), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 0e7934930..770ae0cbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -37,8 +37,13 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch sealed class SendState { @@ -70,6 +75,14 @@ sealed class ReceiveState { ) : ReceiveState() } +enum class TransactionFilter { + ALL, + ZAPS, + NON_ZAPS, +} + +private const val NWC_TIMEOUT_MS = 30_000L + class WalletViewModel : ViewModel() { private var account: Account? = null @@ -82,12 +95,31 @@ class WalletViewModel : ViewModel() { private val _walletAlias = MutableStateFlow(null) val walletAlias = _walletAlias.asStateFlow() - private val _transactions = MutableStateFlow>(emptyList()) - val transactions = _transactions.asStateFlow() + private val allTransactions = MutableStateFlow>(emptyList()) + + private val _transactionFilter = MutableStateFlow(TransactionFilter.ALL) + val transactionFilter = _transactionFilter.asStateFlow() + + val filteredTransactions = + combine(allTransactions, _transactionFilter) { txs, filter -> + when (filter) { + TransactionFilter.ALL -> txs + TransactionFilter.ZAPS -> txs.filter { it.parsedMetadata()?.nostr != null } + TransactionFilter.NON_ZAPS -> txs.filter { it.parsedMetadata()?.nostr == null } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore = _isLoadingMore.asStateFlow() + + private val _hasMoreTransactions = MutableStateFlow(true) + val hasMoreTransactions = _hasMoreTransactions.asStateFlow() + + private val pageSize = 20 + private val _error = MutableStateFlow(null) val error = _error.asStateFlow() @@ -97,6 +129,13 @@ class WalletViewModel : ViewModel() { private val _receiveState = MutableStateFlow(ReceiveState.Idle) val receiveState = _receiveState.asStateFlow() + private fun launchTimeout(onTimeout: () -> Unit): Job = + viewModelScope.launch(Dispatchers.IO) { + delay(NWC_TIMEOUT_MS) + _error.value = "Wallet request timed out" + onTimeout() + } + fun init(account: Account) { this.account = account _hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup() @@ -111,8 +150,10 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true _error.value = null + val timeoutJob = launchTimeout { _isLoading.value = false } try { acc.sendNwcRequest(GetBalanceMethod.create()) { response -> + timeoutJob.cancel() when (response) { is GetBalanceSuccessResponse -> { // NWC balance is in millisats, convert to sats @@ -128,6 +169,7 @@ class WalletViewModel : ViewModel() { _isLoading.value = false } } catch (e: Exception) { + timeoutJob.cancel() _error.value = e.message _isLoading.value = false } @@ -153,24 +195,32 @@ class WalletViewModel : ViewModel() { } } - fun fetchTransactions( - limit: Int = 20, - offset: Int = 0, - ) { + fun fetchTransactions() { val acc = account ?: return viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true + _hasMoreTransactions.value = true + val timeoutJob = launchTimeout { _isLoading.value = false } try { acc.sendNwcRequest( ListTransactionsMethod.create( - limit = limit, - offset = offset, + limit = pageSize, + offset = 0, unpaid = false, ), ) { response -> + timeoutJob.cancel() when (response) { is ListTransactionsSuccessResponse -> { - _transactions.value = response.result?.transactions ?: emptyList() + val txs = response.result?.transactions ?: emptyList() + allTransactions.value = txs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + txs.size < totalCount + } else { + txs.size >= pageSize + } } is NwcErrorResponse -> { @@ -182,12 +232,59 @@ class WalletViewModel : ViewModel() { _isLoading.value = false } } catch (e: Exception) { + timeoutJob.cancel() _error.value = e.message _isLoading.value = false } } } + fun loadMoreTransactions() { + if (_isLoadingMore.value || !_hasMoreTransactions.value) return + val acc = account ?: return + val currentOffset = allTransactions.value.size + viewModelScope.launch(Dispatchers.IO) { + _isLoadingMore.value = true + val timeoutJob = launchTimeout { _isLoadingMore.value = false } + try { + acc.sendNwcRequest( + ListTransactionsMethod.create( + limit = pageSize, + offset = currentOffset, + unpaid = false, + ), + ) { response -> + timeoutJob.cancel() + when (response) { + is ListTransactionsSuccessResponse -> { + val newTxs = response.result?.transactions ?: emptyList() + + allTransactions.value += newTxs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + allTransactions.value.size < totalCount + } else { + newTxs.size >= pageSize + } + } + + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Failed to load more transactions" + } + + else -> {} + } + _isLoadingMore.value = false + } + } catch (e: Exception) { + timeoutJob.cancel() + _error.value = e.message + _isLoadingMore.value = false + } + } + } + fun sendPayment(bolt11: String) { val acc = account ?: return viewModelScope.launch(Dispatchers.IO) { @@ -280,4 +377,8 @@ class WalletViewModel : ViewModel() { fun clearError() { _error.value = null } + + fun setTransactionFilter(filter: TransactionFilter) { + _transactionFilter.value = filter + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 16fa9804b..dfc0916f5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1216,6 +1216,13 @@ Compression Cancelled Compression failed to return a file + Encrypt files + Encrypt files before uploading for privacy. Some servers may not accept encrypted files on free accounts. + Encrypted upload failed + Many servers do not accept encrypted files on free accounts. You can retry without encryption. + Retry without encryption + Warning: Without encryption, anyone with the file link can see the content. + Media Quality Select Low quality to compress your media to a smaller file with less quality, High quality to compress to a larger file with higher quality or Uncompressed to upload the media without compression. Low @@ -1260,6 +1267,9 @@ Received Sent Refresh + All + Zaps + Non-Zaps Security Filters Import Follows diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index fe00ca44c..b338438ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -131,7 +131,10 @@ class ListTransactionsMethod( type: String? = null, unpaid_outgoing: Boolean? = null, unpaid_incoming: Boolean? = null, - ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type)) + ): ListTransactionsMethod = + ListTransactionsMethod( + ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type), + ) } }