feat: compose NIP-29 group threads in the full ShortNotePostScreen

The group "new thread" FAB opened a cramped title+body screen. Point it at the
rich ShortNotePostScreen instead (attachments, emoji, previews, markdown), and
teach that composer to emit a kind-11 group thread when opened for a group.

- Route.NewShortNote gains groupThreadId + groupThreadRelayUrl; the group Threads
  FAB navigates there.
- ShortNotePostViewModel.setGroupThread arms a group-thread mode: createTemplate
  builds a kind-11 ThreadEvent (title = first line, body = the rest, `h` scope),
  and sendPostSync publishes it ONLY to the group's host relay via
  signAndSendPrivatelyOrBroadcast — bypassing the outbox/private/scheduled paths.
- The poll, private-note and scheduling toggles are hidden in group-thread mode
  (they don't apply / would break the host-relay pin).
- Delete the now-unused RelayGroupNewThreadScreen and its route/nav dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-09 23:35:40 +00:00
parent 6292ceb803
commit 1ecc97de76
6 changed files with 89 additions and 151 deletions
@@ -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<Route.RelayGroupNewThread> {
RelayGroupNewThreadScreen(
id = it.id,
relayUrl = it.relayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupCreate> {
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,
)
@@ -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
@@ -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),
)
}
}
}
}
@@ -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(
@@ -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)
}
@@ -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<NormalizedRelayUrl>,
)
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