Merge pull request #2010 from vitorpamplona/claude/nip43-relay-members-dWO19

Add NIP-43 relay members support with UI screens
This commit is contained in:
Vitor Pamplona
2026-03-29 10:17:29 -04:00
committed by GitHub
21 changed files with 1262 additions and 0 deletions
@@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
@@ -237,6 +238,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.RelayManagement> { RelayManagementScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.RelayMembers> { RelayMembersScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
@@ -230,6 +230,10 @@ sealed class Route {
val url: String,
) : Route()
@Serializable data class RelayMembers(
val url: String,
) : Route()
@Serializable data class RelayFeed(
val url: String,
) : Route()
@@ -144,7 +144,12 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember
import com.vitorpamplona.amethyst.ui.note.types.RenderReport
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
@@ -220,6 +225,11 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -917,6 +927,26 @@ private fun RenderNoteRow(
RenderRelayDiscovery(baseNote, accountViewModel, nav)
}
is RelayMembershipListEvent -> {
RenderRelayMembershipList(baseNote, accountViewModel, nav)
}
is RelayAddMemberEvent -> {
RenderRelayAddMember(baseNote, accountViewModel, nav)
}
is RelayRemoveMemberEvent -> {
RenderRelayRemoveMember(baseNote, accountViewModel, nav)
}
is RelayJoinRequestEvent -> {
RenderRelayJoinRequest(baseNote, accountViewModel, nav)
}
is RelayLeaveRequestEvent -> {
RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
}
is PinListEvent -> {
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
}
@@ -0,0 +1,251 @@
/*
* Copyright (c) 2025 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.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
@Composable
fun RenderRelayMembershipList(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? RelayMembershipListEvent ?: return
val memberCount = remember(noteEvent) { noteEvent.members().size }
RelayMemberEventCard(
icon = Icons.Default.People,
title = stringRes(R.string.relay_membership_list),
subtitle = stringRes(R.string.relay_members_count, memberCount),
nav = nav,
relayPubKey = noteEvent.pubKey,
)
}
@Composable
fun RenderRelayAddMember(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? RelayAddMemberEvent ?: return
val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(R.string.relay_member_added)
} else {
stringRes(R.string.relay_members_added, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = Icons.Default.PersonAdd,
title = title,
subtitle = subtitle,
relayPubKey = noteEvent.pubKey,
)
}
@Composable
fun RenderRelayRemoveMember(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? RelayRemoveMemberEvent ?: return
val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(R.string.relay_member_removed)
} else {
stringRes(R.string.relay_members_removed, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = Icons.Default.PersonRemove,
title = title,
subtitle = subtitle,
relayPubKey = noteEvent.pubKey,
)
}
@Composable
fun RenderRelayJoinRequest(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayMemberEventCard(
icon = Icons.Default.PersonAdd,
title = stringRes(R.string.relay_join_request),
subtitle = null,
nav = nav,
relayPubKey = null,
)
}
@Composable
fun RenderRelayLeaveRequest(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayMemberEventCard(
icon = Icons.AutoMirrored.Filled.ExitToApp,
title = stringRes(R.string.relay_leave_request),
subtitle = null,
nav = nav,
relayPubKey = null,
)
}
@Composable
private fun RelayMemberEventCard(
icon: ImageVector,
title: String,
subtitle: String?,
nav: INav? = null,
relayPubKey: String? = null,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Preview
@Composable
private fun RelayMembershipListCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = Icons.Default.People,
title = "Relay membership list",
subtitle = "42 members",
)
}
}
@Preview
@Composable
private fun RelayAddMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = Icons.Default.PersonAdd,
title = "Member added to relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayRemoveMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = Icons.Default.PersonRemove,
title = "Member removed from relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayJoinRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = Icons.Default.PersonAdd,
title = "Relay join request",
subtitle = null,
)
}
}
@Preview
@Composable
private fun RelayLeaveRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = Icons.AutoMirrored.Filled.ExitToApp,
title = "Relay leave request",
subtitle = null,
)
}
}
@@ -63,6 +63,7 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Payment
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
@@ -1143,6 +1144,21 @@ private fun RelayHeader(
Text(text = stringRes(R.string.see_relay_feed))
}
if (supportsNip43(relayInfo.supported_nips)) {
OutlinedButton(
onClick = { nav.nav(Route.RelayMembers(relay.url)) },
shape = ButtonBorder,
) {
Icon(
Icons.Default.People,
contentDescription = stringRes(R.string.relay_members),
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.relay_members))
}
}
if (Nip86Client.supportsNip86(relayInfo.supported_nips)) {
OutlinedButton(
onClick = { nav.nav(Route.RelayManagement(relay.url)) },
@@ -1159,6 +1175,8 @@ private fun RelayHeader(
}
}
fun supportsNip43(supportedNips: List<String>?): Boolean = supportedNips?.any { it == "43" } == true
@Composable
fun FeesCard(
fees: Nip11RelayInformation.RelayInformationFees,
@@ -0,0 +1,384 @@
/*
* Copyright (c) 2025 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.screen.loggedIn.relays.nip43
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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
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.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RelayMembersScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val normalizedRelayUrl = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) }
if (normalizedRelayUrl == null) return
var members by remember { mutableStateOf<List<HexKey>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var isMember by remember { mutableStateOf(false) }
var joinRequestSent by remember { mutableStateOf(false) }
var leaveRequestSent by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(normalizedRelayUrl) {
launch(Dispatchers.IO) {
val filter =
Filter(
kinds = listOf(RelayMembershipListEvent.KIND),
limit = 1,
)
val events =
accountViewModel.account.client
.fetchAsFlow(normalizedRelayUrl, filter)
.lastOrNull()
val membershipEvent =
events
?.mapNotNull { it as? RelayMembershipListEvent }
?.maxByOrNull { it.createdAt }
val memberList = membershipEvent?.members() ?: emptyList()
members = memberList
isMember = memberList.contains(accountViewModel.account.signer.pubKey)
isLoading = false
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringRes(R.string.relay_members_title, normalizedRelayUrl.displayUrl()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding),
) {
MembershipActions(
isMember = isMember,
isLoading = isLoading,
joinRequestSent = joinRequestSent,
leaveRequestSent = leaveRequestSent,
onJoinRequest = {
scope.launch(Dispatchers.IO) {
sendJoinRequest(normalizedRelayUrl, accountViewModel)
joinRequestSent = true
}
},
onLeaveRequest = {
scope.launch(Dispatchers.IO) {
sendLeaveRequest(normalizedRelayUrl, accountViewModel)
leaveRequestSent = true
}
},
)
HorizontalDivider()
if (isLoading) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text(text = stringRes(R.string.relay_members_loading))
}
} else if (members.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.People,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringRes(R.string.relay_members_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Text(
text = stringRes(R.string.relay_members_count, members.size),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(members, key = { it }) { memberPubKey ->
val user = remember(memberPubKey) { accountViewModel.account.cache.getOrCreateUser(memberPubKey) }
UserCompose(
baseUser = user,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
}
@Composable
fun MembershipActions(
isMember: Boolean,
isLoading: Boolean,
joinRequestSent: Boolean,
leaveRequestSent: Boolean,
onJoinRequest: () -> Unit,
onLeaveRequest: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (isLoading) return@Row
if (isMember) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringRes(R.string.relay_members_you_are_member),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(16.dp))
if (leaveRequestSent) {
Text(
text = stringRes(R.string.relay_members_leave_sent),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
OutlinedButton(
onClick = onLeaveRequest,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.relay_members_request_leave))
}
}
} else {
if (joinRequestSent) {
Text(
text = stringRes(R.string.relay_members_join_sent),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
} else {
Button(onClick = onJoinRequest) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.relay_members_request_join))
}
}
}
}
}
@Preview
@Composable
private fun MembershipActionsNotMemberPreview() {
ThemeComparisonColumn {
MembershipActions(
isMember = false,
isLoading = false,
joinRequestSent = false,
leaveRequestSent = false,
onJoinRequest = {},
onLeaveRequest = {},
)
}
}
@Preview
@Composable
private fun MembershipActionsIsMemberPreview() {
ThemeComparisonColumn {
MembershipActions(
isMember = true,
isLoading = false,
joinRequestSent = false,
leaveRequestSent = false,
onJoinRequest = {},
onLeaveRequest = {},
)
}
}
@Preview
@Composable
private fun MembershipActionsJoinSentPreview() {
ThemeComparisonColumn {
MembershipActions(
isMember = false,
isLoading = false,
joinRequestSent = true,
leaveRequestSent = false,
onJoinRequest = {},
onLeaveRequest = {},
)
}
}
@Preview
@Composable
private fun MembershipActionsLeaveSentPreview() {
ThemeComparisonColumn {
MembershipActions(
isMember = true,
isLoading = false,
joinRequestSent = false,
leaveRequestSent = true,
onJoinRequest = {},
onLeaveRequest = {},
)
}
}
suspend fun sendJoinRequest(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
) {
val template = RelayJoinRequestEvent.build()
val signedEvent = accountViewModel.account.signer.sign(template)
accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent)
accountViewModel.account.client.publish(signedEvent, setOf(relay))
}
suspend fun sendLeaveRequest(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
) {
val template = RelayLeaveRequestEvent.build()
val signedEvent = accountViewModel.account.signer.sign(template)
accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent)
accountViewModel.account.client.publish(signedEvent, setOf(relay))
}
@@ -165,7 +165,12 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
@@ -231,6 +236,11 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -712,6 +722,16 @@ private fun FullBleedNoteCompose(
RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is RelayDiscoveryEvent) {
RenderRelayDiscovery(baseNote, accountViewModel, nav)
} else if (noteEvent is RelayMembershipListEvent) {
RenderRelayMembershipList(baseNote, accountViewModel, nav)
} else if (noteEvent is RelayAddMemberEvent) {
RenderRelayAddMember(baseNote, accountViewModel, nav)
} else if (noteEvent is RelayRemoveMemberEvent) {
RenderRelayRemoveMember(baseNote, accountViewModel, nav)
} else if (noteEvent is RelayJoinRequestEvent) {
RenderRelayJoinRequest(baseNote, accountViewModel, nav)
} else if (noteEvent is RelayLeaveRequestEvent) {
RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
} else if (noteEvent is TextNoteModificationEvent) {
RenderTextModificationEvent(
note = baseNote,
+17
View File
@@ -2084,4 +2084,21 @@
<string name="relay_management_relay_icon_url">Relay Icon URL</string>
<string name="relay_management_button">Manage Relay</string>
<string name="manage">Manage</string>
<string name="relay_members">Members</string>
<string name="relay_members_title">Members of %1$s</string>
<string name="relay_members_count">%1$d members</string>
<string name="relay_members_loading">Loading members…</string>
<string name="relay_members_empty">No members found</string>
<string name="relay_members_request_join">Request to Join</string>
<string name="relay_members_request_leave">Request to Leave</string>
<string name="relay_members_join_sent">Join request sent</string>
<string name="relay_members_leave_sent">Leave request sent</string>
<string name="relay_members_you_are_member">You are a member</string>
<string name="relay_membership_list">Relay membership list</string>
<string name="relay_member_added">Member added to relay</string>
<string name="relay_members_added">%1$d members added to relay</string>
<string name="relay_member_removed">Member removed from relay</string>
<string name="relay_members_removed">%1$d members removed from relay</string>
<string name="relay_join_request">Relay join request</string>
<string name="relay_leave_request">Relay leave request</string>
</resources>
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.addMember
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelayAddMemberEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun memberPubKeys() = tags.mapNotNull(PTag::parseKey)
companion object {
const val KIND = 8000
const val ALT_DESCRIPTION = "Relay member added"
fun build(
memberPubKeys: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayAddMemberEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
protect()
memberPubKeys.forEach { add(PTag.assemble(it, null)) }
initializer()
}
}
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.inviteRequest
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelayInviteRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 28935
const val ALT_DESCRIPTION = "Relay invite request"
fun build(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayInviteRequestEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
initializer()
}
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.joinRequest
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelayJoinRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun claim() = tags.claim()
companion object {
const val KIND = 28934
const val ALT_DESCRIPTION = "Relay join request"
fun build(
claim: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayJoinRequestEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
claim?.let { claim(it) }
initializer()
}
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.joinRequest
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.tags.ClaimTag
fun TagArrayBuilder<RelayJoinRequestEvent>.claim(claim: String) = addUnique(ClaimTag.assemble(claim))
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.joinRequest
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.tags.ClaimTag
fun TagArray.claim() = firstNotNullOfOrNull(ClaimTag::parse)
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.joinRequest.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ClaimTag {
companion object {
const val TAG_NAME = "claim"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(claim: String) = arrayOf(TAG_NAME, claim)
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.leaveRequest
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelayLeaveRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 28936
const val ALT_DESCRIPTION = "Relay leave request"
fun build(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayLeaveRequestEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
protect()
initializer()
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelayMembershipListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun members() = tags.members()
companion object {
const val KIND = 13534
const val ALT_DESCRIPTION = "Relay membership list"
fun build(
members: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayMembershipListEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
protect()
members(members)
initializer()
}
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.list
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag
fun TagArrayBuilder<RelayMembershipListEvent>.members(pubKeys: List<HexKey>) = addAll(MemberTag.assemble(pubKeys))
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.list
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag
fun TagArray.members() = mapNotNull(MemberTag::parse)
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.list.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class MemberTag {
companion object {
const val TAG_NAME = "member"
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun assemble(pubKey: HexKey) = arrayOf(TAG_NAME, pubKey)
fun assemble(pubKeys: List<HexKey>) = pubKeys.map { assemble(it) }
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 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.quartz.nip43RelayMembers.removeMember
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class RelayRemoveMemberEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun memberPubKeys() = tags.mapNotNull(PTag::parseKey)
companion object {
const val KIND = 8001
const val ALT_DESCRIPTION = "Relay member removed"
fun build(
memberPubKeys: List<HexKey>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayRemoveMemberEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
protect()
memberPubKeys.forEach { add(PTag.assemble(it, null)) }
initializer()
}
}
}
@@ -79,6 +79,12 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.inviteRequest.RelayInviteRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
@@ -325,6 +331,12 @@ class EventFactory {
EventAssertionEvent.KIND -> EventAssertionEvent(id, pubKey, createdAt, tags, content, sig)
AddressableAssertionEvent.KIND -> AddressableAssertionEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdAssertionEvent.KIND -> ExternalIdAssertionEvent(id, pubKey, createdAt, tags, content, sig)
RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig)
RelayJoinRequestEvent.KIND -> RelayJoinRequestEvent(id, pubKey, createdAt, tags, content, sig)
RelayInviteRequestEvent.KIND -> RelayInviteRequestEvent(id, pubKey, createdAt, tags, content, sig)
RelayLeaveRequestEvent.KIND -> RelayLeaveRequestEvent(id, pubKey, createdAt, tags, content, sig)
RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig)
RelayDiscoveryEvent.KIND -> RelayDiscoveryEvent(id, pubKey, createdAt, tags, content, sig)
RelayMonitorEvent.KIND -> RelayMonitorEvent(id, pubKey, createdAt, tags, content, sig)