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 85f86e9c1e..0be3941fad 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 @@ -112,7 +112,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupDiscoveryScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupEditScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupNewThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen @@ -605,15 +604,6 @@ fun BuildNavigation( ) } - composableFromEndArgs { - RelayGroupNewThreadScreen( - id = it.id, - relayUrl = it.relayUrl, - accountViewModel = accountViewModel, - nav = nav, - ) - } - composableFromEndArgs { RelayGroupCreateScreen( relayUrl = it.relayUrl, @@ -750,6 +740,8 @@ fun BuildNavigation( forkId = it.fork, versionId = it.version, draftId = it.draft, + groupThreadId = it.groupThreadId, + groupThreadRelayUrl = it.groupThreadRelayUrl, accountViewModel = accountViewModel, nav = 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 7962a3d383..e16b15383d 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 @@ -663,11 +663,6 @@ sealed class Route { val relayUrl: String, ) : Route() - @Serializable data class RelayGroupNewThread( - val id: String, - val relayUrl: String, - ) : Route() - @Serializable data class RelayGroupCreate( val relayUrl: String, ) : Route() @@ -844,6 +839,10 @@ sealed class Route { val fork: String? = null, val version: String? = null, val draft: String? = null, + // When set, the composer produces a NIP-29 kind-11 group thread scoped to this group id and + // published only to its host relay (see ShortNotePostViewModel.setGroupThread). + val groupThreadId: String? = null, + val groupThreadRelayUrl: String? = null, ) : Route() @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt deleted file mode 100644 index 34443cf40b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupNewThreadScreen.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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.chats.publicChannels.relayGroup - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -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.navigation.topbars.TopBarExtensibleWithBackButton -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.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip29RelayGroups.GroupId - -/** - * Full-screen composer for a new kind-11 group thread (a title plus a body). Replaces - * the old cramped dialog: state is [rememberSaveable] so a half-written thread survives - * rotation / process death, and the group's host relay accepts it only from a member. - */ -@Composable -fun RelayGroupNewThreadScreen( - id: HexKey, - relayUrl: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return - val groupId = remember(id, relay) { GroupId(id, relay) } - - var title by rememberSaveable { mutableStateOf("") } - var body by rememberSaveable { mutableStateOf("") } - - LoadRelayGroupChannel(groupId, accountViewModel) { channel -> - Scaffold( - topBar = { - TopBarExtensibleWithBackButton( - title = { - Column { - Text( - text = stringRes(R.string.relay_group_thread_new), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = channel.toBestDisplayName(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - }, - actions = { - TextButton( - enabled = title.isNotBlank() && body.isNotBlank(), - onClick = { - accountViewModel.postRelayGroupThread(channel, title.trim(), body.trim()) - nav.popBack() - }, - ) { - Text(stringRes(R.string.relay_group_thread_post)) - } - }, - popBack = nav::popBack, - ) - }, - ) { padding -> - Column( - Modifier - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - OutlinedTextField( - value = title, - onValueChange = { title = it }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_thread_title_label)) }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = body, - onValueChange = { body = it }, - label = { Text(stringRes(R.string.relay_group_thread_body_label)) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp).padding(top = 8.dp), - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index 7eaf2d5f8b..d38d971d05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -127,7 +127,14 @@ private fun RelayGroupThreads( floatingActionButton = { if (canPost) { FloatingActionButton( - onClick = { nav.nav(Route.RelayGroupNewThread(channel.groupId.id, channel.groupId.relayUrl.url)) }, + onClick = { + nav.nav( + Route.NewShortNote( + groupThreadId = channel.groupId.id, + groupThreadRelayUrl = channel.groupId.relayUrl.url, + ), + ) + }, shape = CircleShape, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 0f1336a672..4bcb503a87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -149,11 +149,14 @@ fun ShortNotePostScreen( forkId: HexKey? = null, versionId: HexKey? = null, draftId: HexKey? = null, + groupThreadId: HexKey? = null, + groupThreadRelayUrl: String? = null, accountViewModel: AccountViewModel, nav: Nav, ) { val postViewModel: ShortNotePostViewModel = viewModel() postViewModel.init(accountViewModel) + postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl) val context = LocalContext.current val activity = context.getActivity() @@ -749,8 +752,9 @@ private fun BottomRowActions( ) // Polls publish kinds that can't travel inside a private wrap, so the - // two toggles are mutually exclusive. - if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll) { + // two toggles are mutually exclusive. Neither a private wrap nor a poll makes sense for a + // NIP-29 group thread (it publishes plainly to the host relay), so hide both there. + if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll && postViewModel.groupThreadTarget == null) { AddPrivateNoteButton( isActive = postViewModel.wantsPrivateNote, isLocked = postViewModel.privateNoteLocked, @@ -759,7 +763,10 @@ private fun BottomRowActions( } } - if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) && !postViewModel.wantsPrivateNote) { + if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) && + !postViewModel.wantsPrivateNote && + postViewModel.groupThreadTarget == null + ) { AddPollButton(postViewModel.wantsPoll || postViewModel.wantsZapPoll) { val isActive = postViewModel.wantsPoll || postViewModel.wantsZapPoll if (isActive) { @@ -790,8 +797,9 @@ private fun BottomRowActions( } // Private wraps are built and sent immediately; scheduling them would - // require wrapping at publish time, so the option is hidden for now. - if (!postViewModel.wantsPrivateNote) { + // require wrapping at publish time, so the option is hidden for now. Scheduling also + // bypasses the host-relay pin, so it's hidden for NIP-29 group threads too. + if (!postViewModel.wantsPrivateNote && postViewModel.groupThreadTarget == null) { ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 0aa6359eb9..4e506fc6af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -94,7 +94,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -117,6 +120,7 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify +import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis @@ -133,6 +137,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType @@ -479,6 +484,28 @@ open class ShortNotePostViewModel : this.emojiSuggestions = EmojiSuggestionState(accountVM.account) } + /** + * When set, this composer produces a NIP-29 kind-11 group thread instead of a kind-1 note: + * the title is the first line, the rest is the body, and it publishes ONLY to the group's host + * [relays] (relay29 authorizes writes by the `h` tag). Toggles that don't apply to a group thread + * (poll, private note, scheduling) are hidden while this is set. + */ + class GroupThreadTarget( + val groupId: HexKey, + val relays: List, + ) + + var groupThreadTarget: GroupThreadTarget? = null + private set + + fun setGroupThread( + groupId: HexKey?, + relayUrl: String?, + ) { + val relay = relayUrl?.let { RelayUrlNormalizer.normalizeOrNull(it) } + groupThreadTarget = if (groupId != null && relay != null) GroupThreadTarget(groupId, listOf(relay)) else null + } + open fun load( replyingTo: Note?, quote: Note?, @@ -920,8 +947,19 @@ open class ShortNotePostViewModel : val anonymous = wantsAnonymousPost val scheduledFor = scheduledForSec val privately = wantsPrivateNote + val threadTarget = groupThreadTarget cancel() + if (threadTarget != null) { + // NIP-29 group thread: publish only to the group's host relay, never the account's + // outbox — bypass the private/scheduled/anonymous paths entirely. + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { threadTarget.relays } + accountViewModel.launchSigner { + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } + return + } + if (privately && template.kind == TextNoteEvent.KIND) { // Gift-wrap to the p-tagged users instead of publishing. Private // wins over the anonymous and scheduled modes: a locked private @@ -1074,7 +1112,30 @@ open class ShortNotePostViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null - return if (wantsPoll) { + val threadTarget = groupThreadTarget + return if (threadTarget != null) { + // NIP-29 kind-11 group thread: title = first line, body = the rest (or the whole line + // when single-line). Scoped to the group by `h`; the host relay authorizes the write. + val text = tagger.message + val firstBreak = text.indexOf('\n') + val title = (if (firstBreak >= 0) text.substring(0, firstBreak) else text).trim() + val body = (if (firstBreak >= 0) text.substring(firstBreak + 1) else text).trim() + + ThreadEvent.build(body, title) { + hTag(threadTarget.groupId) + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else if (wantsPoll) { val options = pollOptions.map { it.value } if (options.isEmpty()) return null