From b0834b8d8adac7630e7fe8b13540cf5c90ec54a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:41:00 +0000 Subject: [PATCH] feat: add compose signature pre-filled in text-based post screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Vq4JQPB9m62nJ8Vdp7xVLN --- .../amethyst/model/UiSettings.kt | 1 + .../amethyst/model/UiSettingsFlow.kt | 9 +++ .../model/preferences/UISharedPreferences.kt | 3 + .../nip22Comments/CommentPostViewModel.kt | 22 ++++++- .../nip22Comments/GenericCommentPostScreen.kt | 3 + .../amethyst/ui/screen/UiSettingsState.kt | 2 + .../nip23LongForm/LongFormPostScreen.kt | 3 + .../nip23LongForm/LongFormPostViewModel.kt | 21 ++++++- .../loggedIn/geohash/GeoHashPostScreen.kt | 3 + .../loggedIn/hashtag/HashtagPostScreen.kt | 3 + .../loggedIn/home/ShortNotePostScreen.kt | 3 + .../loggedIn/home/ShortNotePostViewModel.kt | 22 ++++++- .../screen/loggedIn/polls/PollPostScreen.kt | 3 + .../settings/ComposeSettingsScreen.kt | 23 +++++++ .../ui/screen/loggedIn/url/UrlPostScreen.kt | 3 + amethyst/src/main/res/values/strings.xml | 5 +- .../ui/text/TextFieldStateExtensions.kt | 15 +++++ .../commons/ui/text/AppendSignatureTest.kt | 60 +++++++++++++++++++ 18 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index e986f5f778..b90861d506 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index d86fc45a51..f84459a560 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -57,6 +57,7 @@ class UiSettingsFlow( val accentColor: MutableStateFlow = MutableStateFlow(AccentColorType.PURPLE), val fontFamily: MutableStateFlow = MutableStateFlow(FontFamilyType.SYSTEM), val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), + val composeSignature: MutableStateFlow = MutableStateFlow(""), ) { val listOfFlows: List> = listOf>( @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 7ce9b27bd0..c101e4bc58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 3c3973b09e..3a9df00e63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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(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>>( 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 2faa873e00..8a4c67b999 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -136,6 +136,9 @@ fun ReplyCommentPostScreen( postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } } + if (draftId == null) { + postViewModel.applySignature() + } } GenericCommentPostScreen(postViewModel, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt index d0a3e1c387..ea6ed3fce7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 35296e88d1..95cdc5577c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index cac2107755..e7ef300063 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index def8f5dc0a..8860ebac00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -76,6 +76,9 @@ fun GeoHashPostScreen( postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } } + if (draftId == null) { + postViewModel.applySignature() + } } GenericCommentPostScreen(postViewModel, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index 962b13e452..c752fb9b01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -76,6 +76,9 @@ fun HashtagPostScreen( postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } } + if (draftId == null) { + postViewModel.applySignature() + } } GenericCommentPostScreen(postViewModel, accountViewModel, nav) 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 8d2bd6c548..0f1336a672 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 @@ -180,6 +180,9 @@ fun ShortNotePostScreen( postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } } + if (draftId == null && forkId == null) { + postViewModel.applySignature() + } } DisposableEffect(nav, activity) { 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 a447dbcdca..0aa6359eb9 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 @@ -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?>(null) var eTags by mutableStateOf?>(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollPostScreen.kt index 6823871679..102f0dc38d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollPostScreen.kt @@ -51,6 +51,9 @@ fun PollPostScreen( postViewModel.onMessageChanged() } postViewModel.wantsPoll = true + if (draftId == null) { + postViewModel.applySignature() + } } NewPostScreenInner(postViewModel, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index aedab64c92..33bd6417e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -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) { + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt index ac27428364..5a2cdbba51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt @@ -78,6 +78,9 @@ fun UrlPostScreen( postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) } } + if (draftId == null) { + postViewModel.applySignature() + } } GenericCommentPostScreen(postViewModel, accountViewModel, nav) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 141dcf4508..38c36cf6ab 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1782,7 +1782,7 @@ opentimestamps, timestamp, ots, proof namecoin, dns, identity, name calendar, events, reminders, rsvp - draft, posting, editor, auto-save + draft, posting, editor, auto-save, signature emoji, reactions, like navigation, tabs, nav bar tabs, feeds, threads, conversations @@ -3622,6 +3622,9 @@ Compose Settings Automatically create drafts Saves a draft event automatically when you type or leave a composer with unsent text and sends to your private outbox relays. + Signature + Added at the end of the message when opening a new post, reply, quote, or article. Leave empty to disable. + Your signature Use This Dismiss Correct diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt index dd1dca8b77..0aa4a68e06 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/text/TextFieldStateExtensions.kt @@ -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() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt new file mode 100644 index 0000000000..3d82764081 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/text/AppendSignatureTest.kt @@ -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()) + } +}