From bf288711a2562499945e1cf7f8c1c7bdfd4dbb11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:14:05 +0000 Subject: [PATCH] feat: optional subject/title field in the ShortNotePostScreen composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a subject line toggled from the composer's bottom row. On a kind-1 note it becomes a NIP-14 `subject` tag; on a NIP-29 kind-11 group thread it becomes the `title` (superseding the first-line-as-title heuristic — that stays as the fallback when the field is left empty). The field is auto-shown for group threads and labelled "Title" there, "Subject" otherwise. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../loggedIn/home/ShortNotePostScreen.kt | 40 +++++++++++++++++++ .../loggedIn/home/ShortNotePostViewModel.kt | 28 +++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) 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 4bcb503a87..b84173874a 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 @@ -45,6 +45,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -350,6 +351,26 @@ private fun NewPostScreenBody( ) } + if (postViewModel.wantsSubject) { + OutlinedTextField( + value = postViewModel.subjectText, + onValueChange = { postViewModel.subjectText = it }, + singleLine = true, + label = { + Text( + stringRes( + if (postViewModel.groupThreadTarget != null) { + R.string.relay_group_thread_title_label + } else { + R.string.messages_new_message_subject + }, + ), + ) + }, + modifier = Modifier.fillMaxWidth().padding(vertical = Size10dp), + ) + } + // Only show text input if no voice message is being posted if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { Row( @@ -788,6 +809,10 @@ private fun BottomRowActions( } } + AddSubjectButton(postViewModel.wantsSubject) { + postViewModel.toggleSubject() + } + MarkAsSensitiveButton(postViewModel.wantsToMarkAsSensitive) { postViewModel.toggleMarkAsSensitive() } @@ -857,6 +882,21 @@ private fun AddPrivateNoteButton( } } +@Composable +private fun AddSubjectButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.AutoMirrored.Article, + contentDescription = stringRes(R.string.messages_new_message_subject), + modifier = Modifier.height(22.dp), + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, 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 4e506fc6af..90ea7d1755 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 @@ -116,6 +116,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.tags.markedETags import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo +import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -310,6 +311,11 @@ open class ShortNotePostViewModel : override var forwardZapTo = mutableStateOf>(SplitBuilder()) override val forwardZapToEditting = TextFieldState() + // Optional subject line: a NIP-14 `subject` tag on a kind-1 note, or the `title` of a NIP-29 + // kind-11 group thread. Toggled from the bottom row; auto-on for group threads. + var wantsSubject by mutableStateOf(false) + var subjectText by mutableStateOf("") + // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) var contentWarningDescription by mutableStateOf("") @@ -504,6 +510,8 @@ open class ShortNotePostViewModel : ) { val relay = relayUrl?.let { RelayUrlNormalizer.normalizeOrNull(it) } groupThreadTarget = if (groupId != null && relay != null) GroupThreadTarget(groupId, listOf(relay)) else null + // A thread wants a title, so surface the field by default when composing one. + if (groupThreadTarget != null) wantsSubject = true } open fun load( @@ -1114,12 +1122,14 @@ open class ShortNotePostViewModel : 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. + // NIP-29 kind-11 group thread. The title is the explicit subject field when filled; + // otherwise it falls back to the first line (body = the rest). Scoped to the group by + // `h`; the host relay authorizes the write. + val subject = if (wantsSubject) subjectText.trim() else "" 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() + val title = subject.ifEmpty { (if (firstBreak >= 0) text.substring(0, firstBreak) else text).trim() } + val body = if (subject.isNotEmpty()) text.trim() else (if (firstBreak >= 0) text.substring(firstBreak + 1) else text).trim() ThreadEvent.build(body, title) { hTag(threadTarget.groupId) @@ -1261,6 +1271,8 @@ open class ShortNotePostViewModel : references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + if (wantsSubject && subjectText.isNotBlank()) subject(subjectText.trim()) + geoHash?.let { geohash(it) } localZapRaiserAmount?.let { zapraiser(it) } zapReceiver?.let { zapSplits(it) } @@ -1434,6 +1446,8 @@ open class ShortNotePostViewModel : zapRaiserAmount.value = null wantsForwardZapTo = false + wantsSubject = false + subjectText = "" wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false @@ -1821,6 +1835,12 @@ open class ShortNotePostViewModel : draftTag.newVersion() } + fun toggleSubject() { + wantsSubject = !wantsSubject + if (!wantsSubject) subjectText = "" + draftTag.newVersion() + } + fun toggleExpirationDate() { wantsExpirationDate = !wantsExpirationDate if (wantsExpirationDate) {