feat: optional subject/title field in the ShortNotePostScreen composer

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-10 00:14:05 +00:00
parent cb90362bae
commit bf288711a2
2 changed files with 64 additions and 4 deletions
@@ -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,
@@ -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<User>>(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) {