Adds a card to setup the DM relay list if the user doesn't have one.

This commit is contained in:
Vitor Pamplona 2024-05-22 17:46:39 -04:00
parent 76103ac057
commit 599fddb369
11 changed files with 900 additions and 82 deletions

View File

@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.Contact
import com.vitorpamplona.quartz.events.ContactListEvent
@ -2545,6 +2546,40 @@ class Account(
}
}
fun getDMRelayList(): ChatMessageRelayListEvent? {
return LocalCache.getOrCreateAddressableNote(
ChatMessageRelayListEvent.createAddressATag(signer.pubKey),
).event as? ChatMessageRelayListEvent
}
fun saveDMRelayList(dmRelays: List<String>) {
if (!isWriteable()) return
val relayListForDMs =
LocalCache.getOrCreateAddressableNote(
ChatMessageRelayListEvent.createAddressATag(signer.pubKey),
).event as? ChatMessageRelayListEvent
if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) {
ChatMessageRelayListEvent.updateRelayList(
earlierVersion = relayListForDMs,
relays = dmRelays,
signer = signer,
) {
Client.send(it)
LocalCache.justConsume(it, null)
}
} else {
ChatMessageRelayListEvent.createFromScratch(
relays = dmRelays,
signer = signer,
) {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
fun setHideDeleteRequestDialog() {
hideDeleteRequestDialog = true
saveable.invalidateData()

View File

@ -0,0 +1,559 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DMRelayListView(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
relayToAdd: String = "",
nav: (String) -> Unit,
) {
val postViewModel: DMRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Text(stringResource(R.string.dm_relays_title))
SaveButton(
onPost = {
postViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
onClose()
},
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(id = R.string.dm_relays_not_found_editing),
)
Spacer(modifier = StdVertSpacer)
Text(
text = stringResource(id = R.string.dm_relays_not_found_examples),
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
DMServerConfig(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
}
}
Spacer(modifier = StdVertSpacer)
DMEditableServerConfig(relayToAdd) { postViewModel.addRelay(it) }
}
}
}
}
@Composable
fun DMServerConfig(
item: DMRelayListViewModel.DMRelaySetupInfo,
onDelete: (DMRelayListViewModel.DMRelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current
relayInfo?.let {
RelayInformationDialog(
onClose = { relayInfo = null },
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav,
)
}
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
DMServerConfigClickableLine(
item = item,
loadProfilePicture = automaticallyShowProfilePicture,
onDelete = onDelete,
accountViewModel = accountViewModel,
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) },
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
}
accountViewModel.toast(
context.getString(R.string.unable_to_download_relay_document),
msg,
)
},
)
},
)
}
@Composable
fun DMServerConfigClickableLine(
item: DMRelayListViewModel.DMRelaySetupInfo,
loadProfilePicture: Boolean,
onDelete: (DMRelayListViewModel.DMRelaySetupInfo) -> Unit,
onClick: () -> Unit,
accountViewModel: AccountViewModel,
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth())
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
RenderStatusRow(
item = item,
modifier = HalfStartPadding.weight(1f),
accountViewModel = accountViewModel,
)
}
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderStatusRow(
item: DMRelayListViewModel.DMRelaySetupInfo,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.read_from_relay),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.read_from_relay,
R.string.read_from_relay_description,
)
},
),
tint = MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = countToHumanReadableBytes(item.downloadCountInBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.Upload,
stringResource(R.string.write_to_relay),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
accountViewModel.toast(
R.string.write_to_relay,
R.string.write_to_relay_description,
)
},
),
tint = MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = countToHumanReadableBytes(item.uploadCountInBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.SyncProblem,
stringResource(R.string.errors),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.errors,
R.string.errors_description,
)
},
),
tint =
if (item.errorCount > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
},
)
Text(
text = countToHumanReadable(item.errorCount, "errors"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.DeleteSweep,
stringResource(R.string.spam),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.spam,
R.string.spam_description,
)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT,
)
.show()
}
},
),
tint =
if (item.spamCount > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
},
)
Text(
text = countToHumanReadable(item.spamCount, "spam"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
}
@Composable
private fun FirstLine(
item: DMRelayListViewModel.DMRelaySetupInfo,
onClick: () -> Unit,
onDelete: (DMRelayListViewModel.DMRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.paidRelay) {
Icon(
imageVector = Icons.Default.Paid,
null,
modifier =
Modifier
.padding(start = 5.dp, top = 1.dp)
.size(14.dp),
tint = MaterialTheme.colorScheme.allGoodColor,
)
}
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) },
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringResource(id = R.string.remove),
modifier =
Modifier
.padding(start = 10.dp)
.size(15.dp),
tint = WarningColor,
)
}
}
}
@Composable
fun DMEditableServerConfig(
relayToAdd: String,
onNewRelay: (DMRelayListViewModel.DMRelaySetupInfo) -> Unit,
) {
var url by remember { mutableStateOf<String>(relayToAdd) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.add_a_relay)) },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
},
singleLine = true,
)
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS =
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
if (url.endsWith(".onion") || url.endsWith(".onion/")) {
"ws://$url"
} else {
"wss://$url"
}
} else {
url
}
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(DMRelayListViewModel.DMRelaySetupInfo(addedWSS))
url = ""
}
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor =
if (url.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.placeholderText
},
),
) {
Text(text = stringResource(id = R.string.add), color = Color.White)
}
}
}

View File

@ -0,0 +1,123 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class DMRelayListViewModel : ViewModel() {
private lateinit var account: Account
private val _relays = MutableStateFlow<List<DMRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
fun create() {
viewModelScope.launch(Dispatchers.IO) {
account.saveDMRelayList(_relays.value.map { it.url })
clear()
}
}
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
}
}
@Immutable
data class DMRelaySetupInfo(
val url: String,
val errorCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
fun clear() {
_relays.update {
val relayList = account.getDMRelayList()?.relays() ?: emptyList()
relayList.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
DMRelaySetupInfo(
relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}
}
fun addRelay(relay: DMRelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(relay) }
}
fun deleteRelay(relay: DMRelaySetupInfo) {
_relays.update { it.minus(relay) }
}
fun deleteAll() {
_relays.update { relays -> emptyList() }
}
fun togglePaidRelay(
relay: DMRelaySetupInfo,
paid: Boolean,
) {
_relays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
}

View File

@ -20,52 +20,45 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.actions.DMRelayListView
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.BigPadding
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
@Preview
@Composable
@ -89,85 +82,93 @@ fun AddInboxRelayForDMCardPreview() {
scope = myCoroutineScope,
)
runBlocking(Dispatchers.IO) {
val createAt = TimeUtils.now()
val list = listOf("wss://inbox.nostr.wine", "wss://vitor.nostr1.com")
val tags = ChatMessageRelayListEvent.createTagArray(list)
val id = "ab"
LocalCache.justConsume(
ChatMessageRelayListEvent(
id = id,
pubKey = pubkey,
createdAt = createAt,
tags = tags,
content = "",
sig = "",
),
null,
)
}
val accountViewModel =
AccountViewModel(
myAccount,
sharedPreferencesViewModel.sharedPrefs,
)
ThemeComparisonColumn {
AddInboxRelayForDMCard(
accountViewModel = accountViewModel,
nav = {},
)
}
}
@Composable
fun ObserveRelayListForDMsAndDisplayIfNotFound(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
ObserveRelayListForDMs(
accountViewModel = accountViewModel,
) { relayListEvent ->
if (relayListEvent == null) {
AddInboxRelayForDMCard(
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun ObserveRelayListForDMs(
accountViewModel: AccountViewModel,
inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit,
) {
ObserveRelayListForDMs(
pubkey = accountViewModel.account.userProfile().pubkeyHex,
accountViewModel = accountViewModel,
) { relayListEvent ->
inner(relayListEvent)
}
}
@Composable
fun ObserveRelayListForDMs(
pubkey: HexKey,
accountViewModel: AccountViewModel,
inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit,
) {
println("AABBCC ObserveRelayListForDMs $pubkey")
LoadAddressableNote(
ChatMessageRelayListEvent.createAddressTag("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
ChatMessageRelayListEvent.createAddressTag(pubkey),
accountViewModel,
) { relayList ->
Text("Test" + relayList)
if (relayList != null) {
ThemeComparisonColumn {
AddInboxRelayForDMCard(
relayList,
accountViewModel,
nav = {},
)
}
val relayListNoteState by relayList.live().metadata.observeAsState()
val relayListEvent = relayListNoteState?.note?.event as? ChatMessageRelayListEvent
println("AABBCC ObserveRelayListForDMs Event $relayListEvent ${relayListNoteState?.note?.idHex}")
inner(relayListEvent)
}
}
}
@Composable
fun AddInboxRelayForDMCard(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val releaseNoteState by baseNote.live().metadata.observeAsState()
val releaseNote = releaseNoteState?.note ?: return
Column(modifier = Modifier.padding(horizontal = Size10dp)) {
Column(modifier = StdPadding) {
Card(
modifier = MaterialTheme.colorScheme.imageModifier,
) {
Column(
modifier = Modifier.padding(16.dp),
modifier = BigPadding,
) {
// Title
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(id = R.string.dm_relays_not_found),
style =
TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
IconButton(
modifier = Size20Modifier,
onClick = { accountViewModel.markDonatedInThisVersion() },
) {
CloseIcon()
}
}
Text(
text = stringResource(id = R.string.dm_relays_not_found),
style =
TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
Spacer(modifier = StdVertSpacer)
@ -177,8 +178,20 @@ fun AddInboxRelayForDMCard(
Spacer(modifier = StdVertSpacer)
Text(
text = stringResource(id = R.string.dm_relays_not_found_examples),
)
Spacer(modifier = StdVertSpacer)
var wantsToEditRelays by remember { mutableStateOf(false) }
if (wantsToEditRelays) {
DMRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
Button(
onClick = {
wantsToEditRelays = true
},
modifier = Modifier.fillMaxWidth(),
) {
@ -186,7 +199,5 @@ fun AddInboxRelayForDMCard(
}
}
}
Spacer(modifier = DoubleVertSpacer)
}
}

View File

@ -104,6 +104,8 @@ import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@ -116,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.findURLs
@ -295,8 +298,14 @@ fun ChatroomScreen(
Column(Modifier.fillMaxHeight()) {
val replyTo = remember { mutableStateOf<Note?>(null) }
ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav)
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true),
modifier =
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true),
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
@ -425,7 +434,10 @@ fun PrivateMessageEditFieldRow(
UploadFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.size(30.dp).padding(start = 2.dp),
modifier =
Modifier
.size(30.dp)
.padding(start = 2.dp),
) {
channelScreenModel.upload(
galleryUri = it,
@ -603,7 +615,8 @@ fun ChatroomHeader(
) {
Column(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.clickable(
onClick = onClick,
),
@ -635,7 +648,10 @@ fun GroupChatroomHeader(
onClick: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(
verticalArrangement = Arrangement.Center,
@ -669,7 +685,10 @@ private fun EditRoomSubjectButton(
}
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { wantsToPost = true },
contentPadding = ZeroPadding,
) {
@ -703,7 +722,10 @@ fun NewSubjectView(
val scope = rememberCoroutineScope()
Column(
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
modifier =
Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -755,7 +777,10 @@ fun NewSubjectView(
OutlinedTextField(
label = { Text(text = stringResource(R.string.messages_new_subject_message)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
value = message.value,
onValueChange = { message.value = it },
placeholder = {
@ -824,6 +849,31 @@ fun LongRoomHeader(
}
}
@Composable
fun DMRelayLine(
baseUser: User,
accountViewModel: AccountViewModel,
) {
ObserveRelayListForDMs(pubkey = baseUser.pubkeyHex, accountViewModel = accountViewModel) {
val relayList = it?.relays()
if (relayList.isNullOrEmpty()) {
Text(
text = stringResource(id = R.string.dm_relays_regular),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
text = stringResource(id = R.string.dm_relays_through, relayList.joinToString(", ") { RelayUrlFormatter.displayUrl(it) }),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun RoomNameOnlyDisplay(
room: ChatroomKey,

View File

@ -672,7 +672,6 @@
<string name="invalid_nip19_uri_description">Amethyst a reçu une URI à ouvrir mais cette URI était invalide : %1$s</string>
<string name="dm_relays_not_found">Configurer vos relais de messagerie privée</string>
<string name="dm_relays_not_found_description">Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages.</string>
<string name="dm_relays_not_found_explanation">Les relais de messagerie DM acceptent n\'importe quel message de n\'importe qui, mais vous permet seulement de les télécharger. Par exemple, inbox.nostr.wine fonctionne de cette façon. </string>
<string name="dm_relays_not_found_create_now">Configurer maintenant</string>
<string name="zap_the_devs_title">Zap les Devs !</string>
<string name="zap_the_devs_description">Votre don nous aide à faire la différence. Chaque sat compte !</string>

View File

@ -671,7 +671,6 @@
<string name="invalid_nip19_uri_description">Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s</string>
<string name="dm_relays_not_found">Állítsd be a Privát postafiókod közvetítőit</string>
<string name="dm_relays_not_found_description">Ez a beállítás mindenkit tájékoztat, hogy melyik közvetítőt használod, amikor üzeneteket küldenek neked. Nélkülük néhány üzenetről lemaradhatsz.</string>
<string name="dm_relays_not_found_explanation">A DM Inbox csomópontok bárkitől bármilyen üzenetet elfogadnak, de csak a letöltésüket teszik lehetővé. Például az inbox.nostr.wine így működik. </string>
<string name="dm_relays_not_found_create_now">Állítsd be most</string>
<string name="zap_the_devs_title">Zap a fejlesztőknek!</string>
<string name="zap_the_devs_description">Az adományod hozzájárul ahhoz, hogy változást érjünk el. Minden sat számít!</string>

View File

@ -672,7 +672,6 @@
<string name="invalid_nip19_uri_description">Amethist ontving een URI om te openen, maar die uri was ongeldig: %1$s</string>
<string name="dm_relays_not_found">Stel uw Privé Postvak relays in</string>
<string name="dm_relays_not_found_description">Deze instelling geeft iedereen informatie over de relays die gebruikt worden bij het verzenden van berichten. Zonder hen mis je mogelijk een bericht.</string>
<string name="dm_relays_not_found_explanation">DM postvak relays accepteren berichten van iedereen, maar kunnen ze alleen downloaden. Bijvoorbeeld, inbox.nostr.wine werkt op deze manier. </string>
<string name="dm_relays_not_found_create_now">Nu instellen</string>
<string name="zap_the_devs_title">Zap de devs!</string>
<string name="zap_the_devs_description">Jouw donatie helpt ons om een verschil te maken. Elke sat telt!</string>

View File

@ -16,6 +16,7 @@
<string name="group_picture">Group Picture</string>
<string name="explicit_content">Explicit Content</string>
<string name="spam">Spam</string>
<string name="spam_description">The number of spamming events coming from this relay</string>
<string name="impersonation">Impersonation</string>
<string name="illegal_behavior">Illegal Behavior</string>
<string name="other">Other</string>
@ -96,6 +97,7 @@
<string name="posts">Posts</string>
<string name="bytes">Bytes</string>
<string name="errors">Errors</string>
<string name="errors_description">The number of connection errors in this session</string>
<string name="home_feed">Home Feed</string>
<string name="private_message_feed">Private Message Feed</string>
<string name="public_chat_feed">Public Chat Feed</string>
@ -446,6 +448,8 @@
<string name="sats_to_complete">Zapraiser at %1$s. %2$s sats to goal</string>
<string name="read_from_relay">Read from Relay</string>
<string name="write_to_relay">Write to Relay</string>
<string name="write_to_relay_description">The amount in bytes that was sent to this relay, including filters and events</string>
<string name="read_from_relay_description">The amount in bytes that was received from this relay, including filters and events</string>
<string name="an_error_occurred_trying_to_get_relay_information">An error occurred trying to get relay information from %1$s</string>
<string name="owner">Owner</string>
<string name="version">Version</string>
@ -799,9 +803,13 @@
<string name="invalid_nip19_uri">Invalid address</string>
<string name="invalid_nip19_uri_description">Amethyst received a URI to open but that uri was invalid: %1$s</string>
<string name="dm_relays_title">DM Inbox Relays</string>
<string name="dm_relays_through">Relays: %1$s</string>
<string name="dm_relays_regular">Using Regular Relays</string>
<string name="dm_relays_not_found">Set up your Private Inbox relays</string>
<string name="dm_relays_not_found_description">This setting informs everybody which relays to use when sending messages to you. Without them you might miss some messages.</string>
<string name="dm_relays_not_found_explanation">DM Inbox relays accept any message from anyone, but only allows you to download them. For example, inbox.nostr.wine operates in this way. </string>
<string name="dm_relays_not_found_description">This setting lets everybody know which relays to use when sending messages to you. Without them you might miss some messages.</string>
<string name="dm_relays_not_found_examples">Good options are:\n - inbox.nostr.wine (paid)\n - you.nostr1.com (personal relays - paid)</string>
<string name="dm_relays_not_found_editing">Insert between 1-3 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them.</string>
<string name="dm_relays_not_found_create_now">Set up now</string>
<string name="zap_the_devs_title">Zap the Devs!</string>

View File

@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@ -57,6 +58,10 @@ class AdvertisedRelayListEvent(
const val KIND = 10002
const val FIXED_D_TAG = ""
fun createAddressTag(pubKey: HexKey): ATag {
return ATag(KIND, pubKey, FIXED_D_TAG, null)
}
fun create(
list: List<AdvertisedRelayInfo>,
signer: NostrSigner,

View File

@ -51,6 +51,10 @@ class ChatMessageRelayListEvent(
const val KIND = 10050
const val FIXED_D_TAG = ""
fun createAddressATag(pubKey: HexKey): ATag {
return ATag(KIND, pubKey, FIXED_D_TAG, null)
}
fun createAddressTag(pubKey: HexKey): String {
return ATag.assembleATag(KIND, pubKey, FIXED_D_TAG)
}
@ -58,7 +62,33 @@ class ChatMessageRelayListEvent(
fun createTagArray(relays: List<String>): Array<Array<String>> {
return relays.map {
arrayOf("relay", it)
}.plusElement(arrayOf("alt", "Relay list for private messages")).toTypedArray()
}.plusElement(arrayOf("alt", "Relay list to receive private messages")).toTypedArray()
}
fun updateRelayList(
earlierVersion: ChatMessageRelayListEvent,
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChatMessageRelayListEvent) -> Unit,
) {
val tags =
earlierVersion.tags.filter { it[0] != "relay" }.plus(
relays.map {
arrayOf("relay", it)
},
).toTypedArray()
signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady)
}
fun createFromScratch(
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChatMessageRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(