feat: add compose signature pre-filled in text-based post screens

Adds a Signature field to Compose Settings (global UI settings, DataStore
persisted). When opening any text-based composer — new note, reply, quote,
poll, NIP-22 comment (reply/hashtag/geohash/url), or a new long-form
article — the signature is appended to the message with a blank line,
keeping the cursor at the start so the user types above it.

Drafts, forks, and version edits are skipped since their content already
carries (or deliberately omits) a signature, and an untouched
signature-only message is treated as blank so closing the composer never
auto-saves a junk draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq4JQPB9m62nJ8Vdp7xVLN
This commit is contained in:
Claude
2026-07-02 03:41:00 +00:00
parent e6728f8393
commit b0834b8d8a
18 changed files with 200 additions and 4 deletions
@@ -57,6 +57,7 @@ data class UiSettings(
val accentColor: AccentColorType = AccentColorType.PURPLE,
val fontFamily: FontFamilyType = FontFamilyType.SYSTEM,
val fontSize: FontSizeType = FontSizeType.NORMAL,
val composeSignature: String = "",
)
enum class ThemeType(
@@ -57,6 +57,7 @@ class UiSettingsFlow(
val accentColor: MutableStateFlow<AccentColorType> = MutableStateFlow(AccentColorType.PURPLE),
val fontFamily: MutableStateFlow<FontFamilyType> = MutableStateFlow(FontFamilyType.SYSTEM),
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -88,6 +89,7 @@ class UiSettingsFlow(
accentColor,
fontFamily,
fontSize,
composeSignature,
)
// emits at every change in any of the propertyes.
@@ -123,6 +125,7 @@ class UiSettingsFlow(
flows[25] as AccentColorType,
flows[26] as FontFamilyType,
flows[27] as FontSizeType,
flows[28] as String,
)
}
@@ -156,6 +159,7 @@ class UiSettingsFlow(
accentColor.value,
fontFamily.value,
fontSize.value,
composeSignature.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -273,6 +277,10 @@ class UiSettingsFlow(
fontSize.tryEmit(torSettings.fontSize)
any = true
}
if (composeSignature.value != torSettings.composeSignature) {
composeSignature.tryEmit(torSettings.composeSignature)
any = true
}
return any
}
@@ -326,6 +334,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.accentColor),
MutableStateFlow(uiSettings.fontFamily),
MutableStateFlow(uiSettings.fontSize),
MutableStateFlow(uiSettings.composeSignature),
)
}
}
@@ -126,6 +126,7 @@ class UiSharedPreferences(
val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color")
val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family")
val UI_FONT_SIZE = stringPreferencesKey("ui.font_size")
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -166,6 +167,7 @@ class UiSharedPreferences(
accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE,
fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM,
fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL,
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -218,6 +220,7 @@ class UiSharedPreferences(
preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name
preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
@@ -160,6 +161,10 @@ open class CommentPostViewModel :
var externalIdentity by mutableStateOf<ExternalId?>(null)
var replyingTo: Note? by mutableStateOf(null)
// The signature pre-filled by applySignature(), so an untouched signature-only
// message is treated as blank instead of auto-saved as a junk draft.
private var appliedSignature: String? = null
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(
emptyList(),
@@ -407,6 +412,20 @@ open class CommentPostViewModel :
urlPreviews.update(message.text.toString())
}
/**
* Pre-fills the signature from Compose Settings at the end of the message. Skipped by
* callers when loading a draft (the draft content already carries — or deliberately
* omits — a signature).
*/
fun applySignature() {
val signature = accountViewModel.settings.composeSignature()
if (signature.isEmpty()) return
appliedSignature = signature
message.appendSignature(signature)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
val draftEvent = draft.event ?: return
if (draftEvent !is CommentEvent) return
@@ -513,7 +532,8 @@ open class CommentPostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
val text = message.text.toString()
if (text.isBlank() || text.trim() == appliedSignature) {
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val attachments = mutableSetOf<Event>()
@@ -136,6 +136,9 @@ fun ReplyCommentPostScreen(
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
if (draftId == null) {
postViewModel.applySignature()
}
}
GenericCommentPostScreen(postViewModel, accountViewModel, nav)
@@ -135,6 +135,8 @@ class UiSettingsState(
fun automaticallyCreateDrafts() = uiSettingsFlow.automaticallyCreateDrafts.value == BooleanType.ALWAYS
fun composeSignature() = uiSettingsFlow.composeSignature.value.trim()
fun isImmersiveScrollingActive() = uiSettingsFlow.automaticallyHideNavigationBars.value == BooleanType.ALWAYS
fun showProfilePictures() = showProfilePictures.value
@@ -140,6 +140,9 @@ fun LongFormPostScreen(
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
val version = versionId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(draft, version)
if (draftId == null && versionId == null) {
postViewModel.applySignature()
}
}
WatchAndLoadMyEmojiList(accountViewModel)
@@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
@@ -151,6 +152,10 @@ class LongFormPostViewModel :
}
}
// The signature pre-filled by applySignature(), so an untouched signature-only
// body is treated as blank instead of auto-saved as a junk draft.
private var appliedSignature: String? = null
var title by mutableStateOf(TextFieldValue(""))
var summary by mutableStateOf(TextFieldValue(""))
var coverImageUrl by mutableStateOf("")
@@ -271,6 +276,19 @@ class LongFormPostViewModel :
}
}
/**
* Pre-fills the signature from Compose Settings at the end of the article body. Skipped
* by callers when loading a draft or editing an existing version (the content already
* carries or deliberately omits a signature).
*/
fun applySignature() {
val signature = accountViewModel.settings.composeSignature()
if (signature.isEmpty()) return
appliedSignature = signature
message.appendSignature(signature)
}
private fun loadFromDraft(draft: Note) {
val draftEvent = draft.event ?: return
if (draftEvent is LongTextNoteEvent) {
@@ -355,7 +373,8 @@ class LongFormPostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank() && title.text.isBlank()) {
val text = message.text.toString()
if ((text.isBlank() || text.trim() == appliedSignature) && title.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val template = createTemplate() ?: return
@@ -76,6 +76,9 @@ fun GeoHashPostScreen(
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
if (draftId == null) {
postViewModel.applySignature()
}
}
GenericCommentPostScreen(postViewModel, accountViewModel, nav)
@@ -76,6 +76,9 @@ fun HashtagPostScreen(
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
if (draftId == null) {
postViewModel.applySignature()
}
}
GenericCommentPostScreen(postViewModel, accountViewModel, nav)
@@ -180,6 +180,9 @@ fun ShortNotePostScreen(
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
if (draftId == null && forkId == null) {
postViewModel.applySignature()
}
}
DisposableEffect(nav, activity) {
@@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
@@ -209,6 +210,10 @@ open class ShortNotePostViewModel :
var originalNote: Note? by mutableStateOf(null)
var forkedFromNote: Note? by mutableStateOf(null)
// The signature pre-filled by applySignature(), so an untouched signature-only
// message is treated as blank instead of auto-saved as a junk draft.
private var appliedSignature: String? = null
var pTags by mutableStateOf<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(null)
@@ -609,6 +614,20 @@ open class ShortNotePostViewModel :
urlPreviews.update(message.text.toString())
}
/**
* Pre-fills the signature from Compose Settings at the end of the message. Skipped by
* callers when loading a draft or forking (the existing content already carries or
* deliberately omits a signature).
*/
fun applySignature() {
val signature = accountViewModel.settings.composeSignature()
if (signature.isEmpty()) return
appliedSignature = signature
message.appendSignature(signature)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
val draftEvent = draft.event ?: return
if (draftEvent is TextNoteEvent) {
@@ -974,7 +993,8 @@ open class ShortNotePostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
val text = message.text.toString()
if (text.isBlank() || text.trim() == appliedSignature) {
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val attachments = mutableSetOf<Event>()
@@ -51,6 +51,9 @@ fun PollPostScreen(
postViewModel.onMessageChanged()
}
postViewModel.wantsPoll = true
if (draftId == null) {
postViewModel.applySignature()
}
}
NewPostScreenInner(postViewModel, accountViewModel, nav)
@@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -109,10 +112,30 @@ fun ComposeSettingsContent(
)
SettingsDivider()
AddClientTagTile(accountViewModel)
SettingsDivider()
SignatureTile(sharedPrefs.composeSignature)
}
}
}
@Composable
private fun SignatureTile(flow: MutableStateFlow<String>) {
val value by flow.collectAsState()
SettingsBlockTile(
icon = MaterialSymbols.EditNote,
title = stringRes(R.string.compose_signature_setting_title),
description = stringRes(R.string.compose_signature_setting_description),
) {
OutlinedTextField(
value = value,
onValueChange = { flow.tryEmit(it) },
placeholder = { Text(stringRes(R.string.compose_signature_setting_hint)) },
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun AddClientTagTile(accountViewModel: AccountViewModel) {
val addClientTag by accountViewModel.account.settings.syncedSettings.security
@@ -78,6 +78,9 @@ fun UrlPostScreen(
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
if (draftId == null) {
postViewModel.applySignature()
}
}
GenericCommentPostScreen(postViewModel, accountViewModel, nav)
+4 -1
View File
@@ -1782,7 +1782,7 @@
<string name="ots_explorer_search_keywords" translatable="false">opentimestamps, timestamp, ots, proof</string>
<string name="namecoin_search_keywords" translatable="false">namecoin, dns, identity, name</string>
<string name="calendar_reminder_search_keywords" translatable="false">calendar, events, reminders, rsvp</string>
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save</string>
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save, signature</string>
<string name="reactions_settings_search_keywords" translatable="false">emoji, reactions, like</string>
<string name="bottom_bar_search_keywords" translatable="false">navigation, tabs, nav bar</string>
<string name="home_tabs_search_keywords" translatable="false">tabs, feeds, threads, conversations</string>
@@ -3622,6 +3622,9 @@
<string name="compose_settings">Compose Settings</string>
<string name="auto_create_drafts_setting_title">Automatically create drafts</string>
<string name="auto_create_drafts_setting_description">Saves a draft event automatically when you type or leave a composer with unsent text and sends to your private outbox relays.</string>
<string name="compose_signature_setting_title">Signature</string>
<string name="compose_signature_setting_description">Added at the end of the message when opening a new post, reply, quote, or article. Leave empty to disable.</string>
<string name="compose_signature_setting_hint">Your signature</string>
<string name="ai_writing_use_this">Use This</string>
<string name="ai_writing_dismiss">Dismiss</string>
<string name="ai_tone_correct">Correct</string>
@@ -32,6 +32,21 @@ fun TextFieldState.setTextAndPlaceCursorAtBeginning(text: String) {
}
}
/**
* Appends [signature] to the end of the field, separated by a blank line, keeping the
* cursor where it was so the user starts typing above it. No-op when the signature is
* already present, so re-running a screen's load logic never duplicates it.
*/
fun TextFieldState.appendSignature(signature: String) {
if (text.contains(signature)) return
edit {
val cursor = selection
append("\n\n")
append(signature)
selection = cursor
}
}
fun TextFieldState.insertUrlAtCursor(url: String) {
edit {
var toInsert = url.trim()
@@ -0,0 +1,60 @@
/*
* 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.commons.ui.text
import androidx.compose.foundation.text.input.TextFieldState
import kotlin.test.Test
import kotlin.test.assertEquals
class AppendSignatureTest {
@Test
fun appendsToEmptyFieldKeepingCursorAtStart() {
val state = TextFieldState()
state.appendSignature("— me")
assertEquals("\n\n— me", state.text.toString())
assertEquals(0, state.selection.start)
assertEquals(0, state.selection.end)
}
@Test
fun appendsAfterExistingContentKeepingCursor() {
val state = TextFieldState("nostr:quote")
state.setTextAndPlaceCursorAtBeginning("nostr:quote")
state.appendSignature("— me")
assertEquals("nostr:quote\n\n— me", state.text.toString())
assertEquals(0, state.selection.start)
assertEquals(0, state.selection.end)
}
@Test
fun doesNotDuplicateWhenSignatureAlreadyPresent() {
val state = TextFieldState()
state.appendSignature("— me")
state.appendSignature("— me")
assertEquals("\n\n— me", state.text.toString())
}
}