Merge branch 'main' into claude/event-sync-screen-sYGtN
This commit is contained in:
+88
-2
@@ -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()
|
||||||
|
|||||||
+9
@@ -302,6 +302,15 @@ fun GroupDMScreenContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postViewModel.encryptedUploadErrorTitle?.let { title ->
|
||||||
|
EncryptedUploadErrorDialog(
|
||||||
|
title = title,
|
||||||
|
message = postViewModel.encryptedUploadErrorMessage ?: "",
|
||||||
|
onDismiss = postViewModel::dismissEncryptedUploadError,
|
||||||
|
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+30
@@ -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
|
||||||
// ------
|
// ------
|
||||||
|
|||||||
+1
@@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog(
|
|||||||
onCancel,
|
onCancel,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
|
isNip17 = channelScreenModel.nip17,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -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 = "",
|
||||||
|
|||||||
+3
@@ -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) {
|
||||||
|
|||||||
+65
-55
@@ -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) },
|
||||||
|
|||||||
+74
-2
@@ -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,
|
||||||
|
|||||||
+110
-9
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user