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 {
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<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?) {
val room = room ?: return
@@ -563,6 +647,8 @@ class ChatNewMessageViewModel :
uploadsWaitingToBeSent = emptyList()
uploadState?.reset()
dismissEncryptedUploadError()
iMetaAttachments.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.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))
}
},
)
}
@@ -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
// ------
@@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog(
onCancel,
accountViewModel,
nav,
isNip17 = channelScreenModel.nip17,
)
}
@@ -39,12 +39,14 @@ class ChatFileUploader(
suspend fun justUploadNIP17(
viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit,
onEncryptedUploadError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) {
val orchestrator = viewState.multiOrchestrator ?: return
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
if (viewState.encryptFiles) {
val cipher = AESGCM()
val results =
@@ -73,6 +75,52 @@ class ChatFileUploader(
} 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 =
orchestrator.upload(
viewState.caption,
viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
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, null)
} else {
null
}
}
onceUploaded(list)
viewState.reset()
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
@@ -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 = "",
@@ -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<SelectedMedia>) {
reset()
this.multiOrchestrator = MultiOrchestrator(uris)
@@ -70,6 +72,7 @@ class ChatFileUploadState(
mediaUploadTracker.finishUpload()
caption = ""
selectedServer = defaultServer
encryptFiles = true
}
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
@@ -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) },
@@ -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,14 +169,61 @@ 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
@@ -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,
@@ -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<String?>(null)
val walletAlias = _walletAlias.asStateFlow()
private val _transactions = MutableStateFlow<List<NwcTransaction>>(emptyList())
val transactions = _transactions.asStateFlow()
private val allTransactions = MutableStateFlow<List<NwcTransaction>>(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<String?>(null)
val error = _error.asStateFlow()
@@ -97,6 +129,13 @@ class WalletViewModel : ViewModel() {
private val _receiveState = MutableStateFlow<ReceiveState>(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
}
}
+10
View File
@@ -1216,6 +1216,13 @@
<string name="compression_cancelled">Compression Cancelled</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_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>
@@ -1260,6 +1267,9 @@
<string name="wallet_incoming">Received</string>
<string name="wallet_outgoing">Sent</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_import_follows">Import Follows</string>
@@ -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),
)
}
}