From b1b9707c5ff0ec13ef1460b333d4e55a7a6ce00c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 03:13:56 +0000 Subject: [PATCH] feat: Implement NIP-43 relay access metadata and membership management Adds full NIP-43 support with Quartz event classes (kinds 13534, 8000, 8001, 28934, 28935, 28936) following the nip88Polls structure pattern. Includes relay membership list screen with join/leave actions and NoteCompose rendering for NIP-43 events in the feed. https://claude.ai/code/session_01PRqe4bBCb9u62oPh8u9sHx --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/note/NoteCompose.kt | 30 ++ .../amethyst/ui/note/types/RelayMembers.kt | 177 ++++++++++ .../loggedIn/relays/RelayInformationScreen.kt | 18 + .../relays/nip43/RelayMembersScreen.kt | 322 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 15 + .../addMember/RelayAddMemberEvent.kt | 59 ++++ .../inviteRequest/RelayInviteRequestEvent.kt | 52 +++ .../joinRequest/RelayJoinRequestEvent.kt | 56 +++ .../joinRequest/TagArrayBuilderExt.kt | 26 ++ .../joinRequest/TagArrayExt.kt | 26 ++ .../joinRequest/tags/ClaimTag.kt | 39 +++ .../leaveRequest/RelayLeaveRequestEvent.kt | 54 +++ .../list/RelayMembershipListEvent.kt | 58 ++++ .../list/TagArrayBuilderExt.kt | 27 ++ .../nip43RelayMembers/list/TagArrayExt.kt | 26 ++ .../nip43RelayMembers/list/tags/MemberTag.kt | 42 +++ .../removeMember/RelayRemoveMemberEvent.kt | 59 ++++ .../quartz/utils/EventFactory.kt | 12 + 20 files changed, 1104 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/addMember/RelayAddMemberEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/tags/ClaimTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/leaveRequest/RelayLeaveRequestEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/removeMember/RelayRemoveMemberEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 6f8bd3d19..8df8f6c71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index d444dba0e..b7e953bc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 11833bcab..6e9832723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt new file mode 100644 index 000000000..83a12aec8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt @@ -0,0 +1,177 @@ +/* + * 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.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.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 memberKey = remember(noteEvent) { noteEvent.memberPubKey() } + + RelayMemberEventCard( + icon = Icons.Default.PersonAdd, + title = stringRes(R.string.relay_member_added), + subtitle = memberKey?.take(16)?.let { "$it..." }, + nav = nav, + relayPubKey = noteEvent.pubKey, + ) +} + +@Composable +fun RenderRelayRemoveMember( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? RelayRemoveMemberEvent ?: return + val memberKey = remember(noteEvent) { noteEvent.memberPubKey() } + + RelayMemberEventCard( + icon = Icons.Default.PersonRemove, + title = stringRes(R.string.relay_member_removed), + subtitle = memberKey?.take(16)?.let { "$it..." }, + nav = nav, + 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, + relayPubKey: String?, +) { + 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, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 9317e0a4a..81ac4d826 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -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?): Boolean = supportedNips?.any { it == "43" } == true + @Composable fun FeesCard( fees: Nip11RelayInformation.RelayInformationFees, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt new file mode 100644 index 000000000..5324046c0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -0,0 +1,322 @@ +/* + * 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.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.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>(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)) + } + } + } + } +} + +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)) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e62ea06c2..81becc5f6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2084,4 +2084,19 @@ Relay Icon URL Manage Relay Manage + Members + Members of %1$s + %1$d members + Loading members… + No members found + Request to Join + Request to Leave + Join request sent + Leave request sent + You are a member + Relay membership list + Member added to relay + Member removed from relay + Relay join request + Relay leave request diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/addMember/RelayAddMemberEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/addMember/RelayAddMemberEvent.kt new file mode 100644 index 000000000..504355cce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/addMember/RelayAddMemberEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun memberPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + companion object { + const val KIND = 8000 + const val ALT_DESCRIPTION = "Relay member added" + + fun build( + memberPubKey: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + protect() + add(PTag.assemble(memberPubKey, null)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt new file mode 100644 index 000000000..e5ad57167 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt new file mode 100644 index 000000000..57c8667d2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + claim?.let { claim(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayBuilderExt.kt new file mode 100644 index 000000000..36902fe86 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayBuilderExt.kt @@ -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.claim(claim: String) = addUnique(ClaimTag.assemble(claim)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayExt.kt new file mode 100644 index 000000000..908924652 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/tags/ClaimTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/tags/ClaimTag.kt new file mode 100644 index 000000000..827a981f5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/tags/ClaimTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/leaveRequest/RelayLeaveRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/leaveRequest/RelayLeaveRequestEvent.kt new file mode 100644 index 000000000..07824cbb3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/leaveRequest/RelayLeaveRequestEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + protect() + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt new file mode 100644 index 000000000..0b22cd675 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt @@ -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>, + 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, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + protect() + members(members) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt new file mode 100644 index 000000000..d622586de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt @@ -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.members(pubKeys: List) = addAll(MemberTag.assemble(pubKeys)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt new file mode 100644 index 000000000..39da8159b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt new file mode 100644 index 000000000..c73004e93 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt @@ -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): 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) = pubKeys.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/removeMember/RelayRemoveMemberEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/removeMember/RelayRemoveMemberEvent.kt new file mode 100644 index 000000000..9d5ea2668 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/removeMember/RelayRemoveMemberEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun memberPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + companion object { + const val KIND = 8001 + const val ALT_DESCRIPTION = "Relay member removed" + + fun build( + memberPubKey: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + protect() + add(PTag.assemble(memberPubKey, null)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 9601b25ac..6c145af61 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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 @@ -310,6 +316,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)