mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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
|
||||
|
||||
-129
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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(
|
||||
|
||||
+13
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+62
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user