diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c28e9fe10..45688e602 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -628,19 +628,19 @@ class Account( return Pair(data, signedEvent) } - fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? { + fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List? = null): Note? { if (!isWriteable()) return null - Client.send(data) + Client.send(data, relayList = relayList) LocalCache.consume(data, null) - Client.send(signedEvent) + Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] } - fun sendHeader(headerInfo: FileHeader): Note? { + fun sendHeader(headerInfo: FileHeader, relayList: List? = null): Note? { if (!isWriteable()) return null val signedEvent = FileHeaderEvent.create( @@ -655,7 +655,7 @@ class Account( privateKey = loggedIn.privKey!! ) - Client.send(signedEvent) + Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] @@ -671,7 +671,8 @@ class Account( zapRaiserAmount: Long? = null, replyingTo: String?, root: String?, - directMentions: Set + directMentions: Set, + relayList: List? = null ) { if (!isWriteable()) return @@ -694,7 +695,7 @@ class Account( privateKey = loggedIn.privKey!! ) - Client.send(signedEvent) + Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent) } @@ -709,7 +710,8 @@ class Account( closedAt: Int?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, - zapRaiserAmount: Long? = null + zapRaiserAmount: Long? = null, + relayList: List? = null ) { if (!isWriteable()) return @@ -733,7 +735,7 @@ class Account( zapRaiserAmount = zapRaiserAmount ) // println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) - Client.send(signedEvent) + Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index f300f41f2..aa24332f0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -73,10 +73,18 @@ object Client : RelayPool.Listener { RelayPool.sendFilterOnlyIfDisconnected() } - fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set? = null, onDone: (() -> Unit)? = null) { + fun send( + signedEvent: EventInterface, + relay: String? = null, + feedTypes: Set? = null, + relayList: List? = null, + onDone: (() -> Unit)? = null + ) { checkNotInMainThread() - if (relay == null) { + if (relayList != null) { + RelayPool.sendToSelectedRelays(relayList, signedEvent) + } else if (relay == null) { RelayPool.send(signedEvent) } else { val useConnectedRelayIfPresent = RelayPool.getRelays(relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index 0d2140c18..e42293631 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -62,6 +62,12 @@ object RelayPool : Relay.Listener { relays.forEach { it.sendFilterOnlyIfDisconnected() } } + fun sendToSelectedRelays(list: List, signedEvent: EventInterface) { + list.forEach { relay -> + relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } + } + } + fun send(signedEvent: EventInterface) { relays.forEach { it.send(signedEvent) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index a8e8c513c..610d77ced 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -56,7 +57,7 @@ open class NewMediaModel : ViewModel() { } } - fun upload(context: Context) { + fun upload(context: Context, relayList: List? = null) { isUploadingImage = true val contentResolver = context.contentResolver @@ -78,7 +79,7 @@ open class NewMediaModel : ViewModel() { uploadingPercentage.value = 0.2f uploadingDescription.value = "Loading" contentResolver.openInputStream(fileUri)?.use { - createNIP95Record(it.readBytes(), contentType, description, sensitiveContent) + createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList) } ?: run { viewModelScope.launch { @@ -98,7 +99,7 @@ open class NewMediaModel : ViewModel() { server = serverToUse, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> - createNIP94Record(imageUrl, mimeType, description, sensitiveContent) + createNIP94Record(imageUrl, mimeType, description, sensitiveContent, relayList = relayList) }, onError = { isUploadingImage = false @@ -138,7 +139,7 @@ open class NewMediaModel : ViewModel() { return !isUploadingImage && galleryUri != null && selectedServer != null } - fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) { + fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List? = null) { uploadingPercentage.value = 0.40f viewModelScope.launch(Dispatchers.IO) { uploadingDescription.value = "Server Processing" @@ -162,7 +163,7 @@ open class NewMediaModel : ViewModel() { onReady = { uploadingPercentage.value = 0.90f uploadingDescription.value = "Sending" - account?.sendHeader(it) + account?.sendHeader(it, relayList) uploadingPercentage.value = 1.00f isUploadingImage = false onceUploaded() @@ -191,7 +192,7 @@ open class NewMediaModel : ViewModel() { } } - fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) { + fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List? = null) { uploadingPercentage.value = 0.30f uploadingDescription.value = "Hashing" @@ -210,7 +211,7 @@ open class NewMediaModel : ViewModel() { if (nip95 != null) { uploadingDescription.value = "Sending" uploadingPercentage.value = 0.60f - account?.sendNip95(nip95.first, nip95.second) + account?.sendNip95(nip95.first, nip95.second, relayList) } uploadingPercentage.value = 1.00f diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 69b8b10d4..b99becd4e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -7,17 +7,21 @@ import android.util.Size import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface @@ -33,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp @@ -70,6 +75,17 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac } } + var showRelaysDialog by remember { + mutableStateOf(false) + } + var relayList = account.activeRelays()?.filter { + it.write + }?.map { + it + } ?: account.convertLocalRelays().filter { + it.write + } + Dialog( onDismissRequest = { onClose() }, properties = DialogProperties( @@ -82,6 +98,20 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac modifier = Modifier .fillMaxWidth() ) { + if (showRelaysDialog) { + RelaySelectionDialog( + list = relayList, + onClose = { + showRelaysDialog = false + }, + onPost = { + relayList = it + }, + accountViewModel = accountViewModel, + nav = nav + ) + } + Column( modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp) .fillMaxWidth() @@ -97,10 +127,26 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac onClose() }) + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { + showRelaysDialog = true + } + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = null, + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colors.onBackground + ) + } + } + PostButton( onPost = { onClose() - postViewModel.upload(context) + postViewModel.upload(context, relayList) postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) } }, isActive = postViewModel.canPost() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 4fec46b38..0aa09784e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -110,6 +110,16 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val scrollState = rememberScrollState() val scope = rememberCoroutineScope() + var showRelaysDialog by remember { + mutableStateOf(false) + } + var relayList = account.activeRelays()?.filter { + it.write + }?.map { + it + } ?: account.convertLocalRelays().filter { + it.write + } LaunchedEffect(Unit) { postViewModel.load(account, baseReplyTo, quote) @@ -144,6 +154,20 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n .fillMaxWidth() .fillMaxHeight() ) { + if (showRelaysDialog) { + RelaySelectionDialog( + list = relayList, + onClose = { + showRelaysDialog = false + }, + onPost = { + relayList = it + }, + accountViewModel = accountViewModel, + nav = nav + ) + } + Column( modifier = Modifier .fillMaxWidth() @@ -165,10 +189,25 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n onClose() }) + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { + showRelaysDialog = true + } + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = null, + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colors.onBackground + ) + } + } PostButton( onPost = { scope.launch(Dispatchers.IO) { - postViewModel.sendPost() + postViewModel.sendPost(relayList = relayList) onClose() } }, @@ -265,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n url, account.defaultFileServer, onAdd = { description, server, sensitiveContent -> - postViewModel.upload(url, description, sensitiveContent, server, context) + postViewModel.upload(url, description, sensitiveContent, server, context, relayList) account.changeDefaultFileServer(server) }, onCancel = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index ec1cbb04a..5c366c367 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.noProtocolUrlValidator +import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.isValidURL import kotlinx.coroutines.Dispatchers @@ -128,7 +129,7 @@ open class NewPostViewModel() : ViewModel() { this.account = account } - fun sendPost() { + fun sendPost(relayList: List? = null) { val tagger = NewMessageTagger(originalNote?.channelHex(), mentions, replyTos, message.text) tagger.run() @@ -145,7 +146,20 @@ open class NewPostViewModel() : ViewModel() { val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null if (wantsPoll) { - account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) + account?.sendPoll( + tagger.message, + tagger.replyTos, + tagger.mentions, + pollOptions, + valueMaximum, + valueMinimum, + consensusThreshold, + closedAt, + zapReceiver, + wantsToMarkAsSensitive, + localZapRaiserAmount, + relayList + ) } else if (originalNote?.channelHex() != null) { if (originalNote is AddressableEvent && originalNote?.address() != null) { account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) @@ -172,14 +186,15 @@ open class NewPostViewModel() : ViewModel() { zapRaiserAmount = localZapRaiserAmount, replyingTo = replyId, root = rootId, - directMentions = tagger.directMentions + directMentions = tagger.directMentions, + relayList = relayList ) } cancel() } - fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context) { + fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List? = null) { isUploadingImage = true contentToAddUrl = null @@ -194,7 +209,7 @@ open class NewPostViewModel() : ViewModel() { onReady = { fileUri, contentType, size -> if (server == ServersAvailable.NIP95) { contentResolver.openInputStream(fileUri)?.use { - createNIP95Record(it.readBytes(), contentType, description, sensitiveContent) + createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList) } } else { ImageUploader.uploadImage( @@ -370,7 +385,7 @@ open class NewPostViewModel() : ViewModel() { } } - fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) { + fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List? = null) { viewModelScope.launch(Dispatchers.IO) { // Images don't seem to be ready immediately after upload FileHeader.prepare( @@ -379,7 +394,7 @@ open class NewPostViewModel() : ViewModel() { description, sensitiveContent, onReady = { - val note = account?.sendHeader(it) + val note = account?.sendHeader(it, relayList = relayList) isUploadingImage = false @@ -401,7 +416,7 @@ open class NewPostViewModel() : ViewModel() { } } - fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) { + fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List? = null) { viewModelScope.launch(Dispatchers.IO) { FileHeader.prepare( bytes, @@ -411,7 +426,7 @@ open class NewPostViewModel() : ViewModel() { sensitiveContent, onReady = { val nip95 = account?.createNip95(bytes, headerInfo = it) - val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) } + val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) } isUploadingImage = false diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt new file mode 100644 index 000000000..a2becfb94 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt @@ -0,0 +1,183 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.model.RelayInformation +import com.vitorpamplona.amethyst.service.relays.Relay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch + +data class RelayList( + val relay: Relay, + val isSelected: Boolean +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RelaySelectionDialog( + list: List, + onClose: () -> Unit, + onPost: (list: List) -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val relayList = accountViewModel.account.activeRelays()?.filter { + it.write + }?.map { + it + } ?: accountViewModel.account.convertLocalRelays().filter { + it.write + } + + var relays by remember { + mutableStateOf( + relayList.map { + RelayList( + it, + list.any { relay -> it.url == relay.url } + ) + } + ) + } + var relayInfo: RelayInformation? by remember { mutableStateOf(null) } + + if (relayInfo != null) { + RelayInformationDialog( + onClose = { + relayInfo = null + }, + relayInfo = relayInfo!!, + accountViewModel, + nav + ) + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(start = 10.dp, end = 10.dp, top = 10.dp) + + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton( + onCancel = { + onClose() + } + ) + + PostButton( + onPost = { + val selectedRelays = relays.filter { it.isSelected } + if (selectedRelays.isEmpty()) { + scope.launch { + Toast.makeText(context, "Select a relay to continue", Toast.LENGTH_SHORT).show() + } + return@PostButton + } + onPost(selectedRelays.map { it.relay }) + onClose() + }, + isActive = true + ) + } + + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ) + ) { + itemsIndexed( + relays, + key = { _, item -> item.relay.url } + ) { index, item -> + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + relays = relays.mapIndexed { j, item -> + if (index == j) { + item.copy(isSelected = !item.isSelected) + } else { + item + } + } + }, + onLongClick = { + loadRelayInfo(item.relay.url, context, scope) { + relayInfo = it + } + } + ) + ) { + Text( + item.relay.url + .removePrefix("ws://") + .removePrefix("wss://") + .removeSuffix("/") + ) + Switch( + checked = item.isSelected, + onCheckedChange = { + relays = relays.mapIndexed { j, item -> + if (index == j) { + item.copy(isSelected = !item.isSelected) + } else { item } + } + } + ) + } + } + } + } + } + } +}