Merge branch 'main' into claude/event-sync-screen-sYGtN

This commit is contained in:
Vitor Pamplona
2026-03-17 13:28:36 -04:00
committed by GitHub
13 changed files with 503 additions and 74 deletions
@@ -403,7 +403,16 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { 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 uploadsWaitingToBeSent += it
draftTag.newVersion() draftTag.newVersion()
onceUploaded() onceUploaded()
@@ -428,7 +437,19 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { 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) ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion() draftTag.newVersion()
onceUploaded() onceUploaded()
@@ -443,6 +464,69 @@ class ChatNewMessageViewModel :
} }
} }
// Encrypted upload error state for retry dialog
var encryptedUploadErrorTitle by mutableStateOf<String?>(null)
var encryptedUploadErrorMessage by mutableStateOf<String?>(null)
var pendingRetryMode by mutableStateOf<RetryMode?>(null)
var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null)
var pendingRetryContext by mutableStateOf<Context?>(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?) { private suspend fun innerSendPost(draftTag: String?) {
val room = room ?: return val room = room ?: return
@@ -563,6 +647,8 @@ class ChatNewMessageViewModel :
uploadsWaitingToBeSent = emptyList() uploadsWaitingToBeSent = emptyList()
uploadState?.reset() uploadState?.reset()
dismissEncryptedUploadError()
iMetaAttachments.reset() iMetaAttachments.reset()
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -302,6 +302,15 @@ fun GroupDMScreenContent(
) )
} }
} }
postViewModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = postViewModel.encryptedUploadErrorMessage ?: "",
onDismiss = postViewModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
)
}
} }
} }
@@ -27,8 +27,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.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.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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( Column(
modifier = EditFieldModifier, modifier = EditFieldModifier,
) { ) {
@@ -217,3 +228,36 @@ fun KeyboardLeadingIcon(
ToggleNip17Button(channelScreenModel, accountViewModel) 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))
}
},
)
}
@@ -24,11 +24,14 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache 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.nip04Dm.messages.PrivateDmEvent 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.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.ciphers.AESGCM
class ChatFileSender( class ChatFileSender(
@@ -39,6 +42,8 @@ class ChatFileSender(
uploads.forEach { uploads.forEach {
if (it.cipher != null) { if (it.cipher != null) {
sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) 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 // NIP 04
// ------ // ------
@@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog(
onCancel, onCancel,
accountViewModel, accountViewModel,
nav, nav,
isNip17 = channelScreenModel.nip17,
) )
} }
@@ -39,20 +39,68 @@ class ChatFileUploader(
suspend fun justUploadNIP17( suspend fun justUploadNIP17(
viewState: ChatFileUploadState, viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
onEncryptedUploadError: (title: String, message: String) -> Unit,
context: Context, context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit, onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) { ) {
val orchestrator = viewState.multiOrchestrator ?: return val orchestrator = viewState.multiOrchestrator ?: return
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) 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<SuccessfulUploads>) -> Unit,
) {
val orchestrator = viewState.multiOrchestrator ?: return
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
val results = val results =
orchestrator.uploadEncrypted( orchestrator.upload(
viewState.caption, viewState.caption,
viewState.contentWarningReason, viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
cipher,
viewState.selectedServer, viewState.selectedServer,
account, account,
context, context,
@@ -62,7 +110,7 @@ class ChatFileUploader(
val list = val list =
results.successful.mapNotNull { state -> results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null)
} else { } else {
null null
} }
@@ -81,6 +81,7 @@ fun ChatFileUploadDialog(
onCancel: () -> Unit, onCancel: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
isNip17: Boolean = false,
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -132,7 +133,7 @@ fun ChatFileUploadDialog(
) { ) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel) ImageVideoPostChat(state, accountViewModel, isNip17)
} }
} }
} }
@@ -144,6 +145,7 @@ fun ChatFileUploadDialog(
private fun ImageVideoPostChat( private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState, fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isNip17: Boolean = false,
) { ) {
val fileServers by accountViewModel.account.blossomServers.hostNameFlow val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState() .collectAsState()
@@ -190,6 +192,16 @@ private fun ImageVideoPostChat(
onCheckedChange = fileUploadState::updateContentWarning, 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) { SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner( TextSpinner(
label = "", label = "",
@@ -55,6 +55,8 @@ class ChatFileUploadState(
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1) var mediaQualitySlider by mutableIntStateOf(1)
var encryptFiles by mutableStateOf(true)
fun load(uris: ImmutableList<SelectedMedia>) { fun load(uris: ImmutableList<SelectedMedia>) {
reset() reset()
this.multiOrchestrator = MultiOrchestrator(uris) this.multiOrchestrator = MultiOrchestrator(uris)
@@ -70,6 +72,7 @@ class ChatFileUploadState(
mediaUploadTracker.finishUpload() mediaUploadTracker.finishUpload()
caption = "" caption = ""
selectedServer = defaultServer selectedServer = defaultServer
encryptFiles = true
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
@@ -21,8 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts
import androidx.compose.animation.animateContentSize 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight 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.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed 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.AlertDialog
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
@@ -55,7 +56,8 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -80,10 +82,67 @@ private fun RenderDraftListScreen(
) { ) {
WatchLifecycleAndUpdateModel(feedState) 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( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { 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, accountViewModel = accountViewModel,
) { ) {
@@ -104,7 +163,6 @@ private fun RenderDraftListScreen(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun DraftFeedLoaded( private fun DraftFeedLoaded(
loaded: FeedState.Loaded, loaded: FeedState.Loaded,
@@ -114,60 +172,12 @@ private fun DraftFeedLoaded(
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() 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( LazyColumn(
contentPadding = FeedPadding, contentPadding = FeedPadding,
state = listState, 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 -> itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) { Row(Modifier.fillMaxWidth()) {
SwipeToDeleteWithConfirmation( SwipeToDeleteWithConfirmation(
modifier = Modifier.fillMaxWidth().animateContentSize(), modifier = Modifier.fillMaxWidth().animateContentSize(),
onDelete = { accountViewModel.delete(item) }, onDelete = { accountViewModel.delete(item) },
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward 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.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -49,6 +51,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -84,8 +87,30 @@ fun WalletTransactionsScreen(
walletViewModel.fetchTransactions() walletViewModel.fetchTransactions()
} }
val transactions by walletViewModel.transactions.collectAsState() val transactions by walletViewModel.filteredTransactions.collectAsState()
val isLoading by walletViewModel.isLoading.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( Scaffold(
topBar = { topBar = {
@@ -144,16 +169,63 @@ fun WalletTransactionsScreen(
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
state = listState,
) { ) {
item {
TransactionFilterRow(currentFilter) { walletViewModel.setTransactionFilter(it) }
}
items(transactions) { tx -> items(transactions) { tx ->
TransactionItem(tx, accountViewModel, nav) TransactionItem(tx, accountViewModel, nav)
HorizontalDivider() 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 @Composable
private fun TransactionItem( private fun TransactionItem(
tx: NwcTransaction, tx: NwcTransaction,
@@ -317,7 +389,7 @@ private fun TransactionUserName(
) )
} else { } else {
Text( Text(
text = fallbackName ?: pubkeyHex.take(8) + "...", text = fallbackName ?: (pubkeyHex.take(8) + "..."),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
@@ -37,8 +37,13 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed class SendState { sealed class SendState {
@@ -70,6 +75,14 @@ sealed class ReceiveState {
) : ReceiveState() ) : ReceiveState()
} }
enum class TransactionFilter {
ALL,
ZAPS,
NON_ZAPS,
}
private const val NWC_TIMEOUT_MS = 30_000L
class WalletViewModel : ViewModel() { class WalletViewModel : ViewModel() {
private var account: Account? = null private var account: Account? = null
@@ -82,12 +95,31 @@ class WalletViewModel : ViewModel() {
private val _walletAlias = MutableStateFlow<String?>(null) private val _walletAlias = MutableStateFlow<String?>(null)
val walletAlias = _walletAlias.asStateFlow() val walletAlias = _walletAlias.asStateFlow()
private val _transactions = MutableStateFlow<List<NwcTransaction>>(emptyList()) private val allTransactions = MutableStateFlow<List<NwcTransaction>>(emptyList())
val transactions = _transactions.asStateFlow()
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) private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow() 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<String?>(null) private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow() val error = _error.asStateFlow()
@@ -97,6 +129,13 @@ class WalletViewModel : ViewModel() {
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle) private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState = _receiveState.asStateFlow() 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) { fun init(account: Account) {
this.account = account this.account = account
_hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup() _hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup()
@@ -111,8 +150,10 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true _isLoading.value = true
_error.value = null _error.value = null
val timeoutJob = launchTimeout { _isLoading.value = false }
try { try {
acc.sendNwcRequest(GetBalanceMethod.create()) { response -> acc.sendNwcRequest(GetBalanceMethod.create()) { response ->
timeoutJob.cancel()
when (response) { when (response) {
is GetBalanceSuccessResponse -> { is GetBalanceSuccessResponse -> {
// NWC balance is in millisats, convert to sats // NWC balance is in millisats, convert to sats
@@ -128,6 +169,7 @@ class WalletViewModel : ViewModel() {
_isLoading.value = false _isLoading.value = false
} }
} catch (e: Exception) { } catch (e: Exception) {
timeoutJob.cancel()
_error.value = e.message _error.value = e.message
_isLoading.value = false _isLoading.value = false
} }
@@ -153,24 +195,32 @@ class WalletViewModel : ViewModel() {
} }
} }
fun fetchTransactions( fun fetchTransactions() {
limit: Int = 20,
offset: Int = 0,
) {
val acc = account ?: return val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true _isLoading.value = true
_hasMoreTransactions.value = true
val timeoutJob = launchTimeout { _isLoading.value = false }
try { try {
acc.sendNwcRequest( acc.sendNwcRequest(
ListTransactionsMethod.create( ListTransactionsMethod.create(
limit = limit, limit = pageSize,
offset = offset, offset = 0,
unpaid = false, unpaid = false,
), ),
) { response -> ) { response ->
timeoutJob.cancel()
when (response) { when (response) {
is ListTransactionsSuccessResponse -> { 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 -> { is NwcErrorResponse -> {
@@ -182,12 +232,59 @@ class WalletViewModel : ViewModel() {
_isLoading.value = false _isLoading.value = false
} }
} catch (e: Exception) { } catch (e: Exception) {
timeoutJob.cancel()
_error.value = e.message _error.value = e.message
_isLoading.value = false _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) { fun sendPayment(bolt11: String) {
val acc = account ?: return val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -280,4 +377,8 @@ class WalletViewModel : ViewModel() {
fun clearError() { fun clearError() {
_error.value = null _error.value = null
} }
fun setTransactionFilter(filter: TransactionFilter) {
_transactionFilter.value = filter
}
} }
+10
View File
@@ -1216,6 +1216,13 @@
<string name="compression_cancelled">Compression Cancelled</string> <string name="compression_cancelled">Compression Cancelled</string>
<string name="compression_returned_null">Compression failed to return a file</string> <string name="compression_returned_null">Compression failed to return a file</string>
<string name="encrypt_files_label">Encrypt files</string>
<string name="encrypt_files_description">Encrypt files before uploading for privacy. Some servers may not accept encrypted files on free accounts.</string>
<string name="failed_to_upload_encrypted_media_title">Encrypted upload failed</string>
<string name="failed_to_upload_encrypted_media_message">Many servers do not accept encrypted files on free accounts. You can retry without encryption.</string>
<string name="retry_without_encryption">Retry without encryption</string>
<string name="upload_without_encryption_warning">Warning: Without encryption, anyone with the file link can see the content.</string>
<string name="media_compression_quality_label">Media Quality</string> <string name="media_compression_quality_label">Media Quality</string>
<string name="media_compression_quality_explainer">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.</string> <string name="media_compression_quality_explainer">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.</string>
<string name="media_compression_quality_low">Low</string> <string name="media_compression_quality_low">Low</string>
@@ -1260,6 +1267,9 @@
<string name="wallet_incoming">Received</string> <string name="wallet_incoming">Received</string>
<string name="wallet_outgoing">Sent</string> <string name="wallet_outgoing">Sent</string>
<string name="wallet_refresh">Refresh</string> <string name="wallet_refresh">Refresh</string>
<string name="wallet_filter_all">All</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Non-Zaps</string>
<string name="route_security_filters">Security Filters</string> <string name="route_security_filters">Security Filters</string>
<string name="route_import_follows">Import Follows</string> <string name="route_import_follows">Import Follows</string>
@@ -131,7 +131,10 @@ class ListTransactionsMethod(
type: String? = null, type: String? = null,
unpaid_outgoing: Boolean? = null, unpaid_outgoing: Boolean? = null,
unpaid_incoming: 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),
)
} }
} }