diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index f49058bee7..065754cc4f 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -218,6 +218,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
signAnonymouslyAndBroadcast(
template: EventTemplate,
broadcast: List = emptyList(),
+ anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
): T {
- val anonymousSigner = NostrSignerInternal(KeyPair())
val event = anonymousSigner.sign(template)
cache.justConsumeMyOwnEvent(event)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt
index 12e44faade..4f9af36bd4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@@ -66,6 +67,7 @@ class MultiOrchestrator(
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
+ forcedSigner: NostrSigner? = null,
): Result {
coroutineScope {
val jobs =
@@ -84,6 +86,7 @@ class MultiOrchestrator(
stripMetadata,
onStrippingFailed,
convertGifToMp4 = convertGifToMp4,
+ forcedSigner = forcedSigner,
)
}
}
@@ -106,6 +109,7 @@ class MultiOrchestrator(
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
+ forcedSigner: NostrSigner? = null,
): Result {
coroutineScope {
val jobs =
@@ -125,6 +129,7 @@ class MultiOrchestrator(
stripMetadata,
onStrippingFailed,
convertGifToMp4 = convertGifToMp4,
+ forcedSigner = forcedSigner,
)
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt
index a230b37cf9..ed5b3e7b84 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt
@@ -30,7 +30,10 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
+import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
+import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.flow.MutableStateFlow
@@ -142,6 +145,7 @@ class UploadOrchestrator {
contentTypeForResult: String?,
originalHash: String?,
account: Account,
+ forcedSigner: NostrSigner?,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
@@ -158,7 +162,12 @@ class UploadOrchestrator {
onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
},
- httpAuth = account::createHTTPAuthorization,
+ httpAuth =
+ if (forcedSigner != null) {
+ { url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) }
+ } else {
+ account::createHTTPAuthorization
+ },
context = context,
)
@@ -187,6 +196,7 @@ class UploadOrchestrator {
contentTypeForResult: String?,
originalHash: String?,
account: Account,
+ forcedSigner: NostrSigner?,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
@@ -201,7 +211,12 @@ class UploadOrchestrator {
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
- httpAuth = account::createBlossomUploadAuth,
+ httpAuth =
+ if (forcedSigner != null) {
+ { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
+ } else {
+ account::createBlossomUploadAuth
+ },
context = context,
)
@@ -360,6 +375,7 @@ class UploadOrchestrator {
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
+ forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
@@ -379,8 +395,8 @@ class UploadOrchestrator {
try {
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
- ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
- ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
+ ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
+ ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
}
} finally {
deleteTempUri(finalUri, uri)
@@ -401,6 +417,7 @@ class UploadOrchestrator {
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
+ forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
@@ -423,8 +440,8 @@ class UploadOrchestrator {
try {
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
- ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context)
- ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context)
+ ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
+ ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
}
} finally {
deleteTempUri(encrypted.uri, uri)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SharedMediaResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SharedMediaResolver.kt
new file mode 100644
index 0000000000..65290b793b
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SharedMediaResolver.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.actions.uploads
+
+import android.content.Context
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Resolves a shared content-URI string (from an Android SEND intent) into a
+ * [SelectedMedia] by reading its MIME type off the main thread. Returns null for
+ * a null/blank URI. Shared by the share-to-compose pre-fill paths (DM chatroom,
+ * group DM, …) so the URI→MIME→SelectedMedia logic lives in one place.
+ */
+suspend fun resolveSharedMedia(
+ context: Context,
+ uriString: String?,
+): SelectedMedia? =
+ uriString?.ifBlank { null }?.toUri()?.let { uri ->
+ withContext(Dispatchers.IO) {
+ SelectedMedia(uri, context.contentResolver.getType(uri))
+ }
+ }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index 67fec8ec37..b05ec5d244 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
@@ -415,7 +416,7 @@ fun BuildNavigation(
composableFromEndArgs { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
- composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
+ composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEnd { MarmotGroupListScreen(accountViewModel, nav) }
@@ -461,6 +462,7 @@ fun BuildNavigation(
composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
+ composableFromBottomArgs { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) }
@@ -593,9 +595,15 @@ private fun NavigateIfIntentRequested(
val activity = LocalContext.current.getActivity()
if (activity.intent.action == Intent.ACTION_SEND) {
- // avoids restarting the new Post screen when the intent is for the screen.
+ val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
+
+ // avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
- if (isBaseRoute(nav.controller)) return
+ if (isShareAsDm) {
+ if (isBaseRoute(nav.controller)) return
+ } else {
+ if (isBaseRoute(nav.controller)) return
+ }
// saves the intent to avoid processing again
var message by remember {
@@ -612,7 +620,19 @@ private fun NavigateIfIntentRequested(
)
}
- nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
+ if (isShareAsDm) {
+ nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
+ } else {
+ nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
+ }
+
+ // Consume the launch intent so a later recomposition can't re-fire
+ // newStack for the same share (the isBaseRoute guard is a non-reactive
+ // snapshot and stops guarding once we navigate past the destination,
+ // e.g. into a chat via the one-shot picker). Clearing the action also
+ // lets the else-branch register the onNewIntent listener for the rest
+ // of this session.
+ activity.intent.action = null
} else {
var newAccount by remember { mutableStateOf(null) }
@@ -671,9 +691,17 @@ private fun NavigateIfIntentRequested(
val consumer =
Consumer { intent ->
if (intent.action == Intent.ACTION_SEND) {
- // avoids restarting the new Post screen when the intent is for the screen.
+ val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
+ // avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
- if (!isBaseRoute(nav.controller)) {
+ if (isShareAsDm) {
+ if (!isBaseRoute(nav.controller)) {
+ val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
+ val attachment =
+ IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
+ nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
+ }
+ } else if (!isBaseRoute(nav.controller)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(Route.NewShortNote(message = it))
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt
new file mode 100644
index 0000000000..c7d3f06194
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.navigation
+
+/**
+ * Distinguishes the "Send as DM" share target from the default "New Post" share
+ * target. Both intent-filters resolve to MainActivity; they are told apart by the
+ * component class name of the launching intent (the activity-alias name).
+ */
+object ShareIntentRouting {
+ /**
+ * Simple class name of the `` declared in AndroidManifest.xml
+ * (android:name=".ui.ShareAsDMAlias"). MUST stay in sync with the manifest —
+ * renaming the alias there without updating this constant silently routes
+ * "Send as DM" shares to the New Post composer (no build error).
+ */
+ const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias"
+
+ fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt
index 6d90c38983..48ef26c9b4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt
@@ -234,7 +234,7 @@ fun routeToMessage(
): Route {
account.chatroomList.getOrCreatePrivateChatroom(room)
- return Route.Room(room, draftMessage, replyId, draftId, expiresDays)
+ return Route.Room(room, message = draftMessage, replyId = replyId, draftId = draftId, expiresDays = expiresDays)
}
fun routeToMessage(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
index 3c8579065a..f0faa2f41e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
@@ -523,16 +523,23 @@ sealed class Route {
val attachment: String? = null,
) : Route()
+ @Serializable data class ShareToDM(
+ val message: String? = null,
+ val attachment: String? = null,
+ ) : Route()
+
@Serializable data class Room(
val id: String,
val message: String? = null,
+ val attachment: String? = null,
val replyId: HexKey? = null,
val draftId: HexKey? = null,
val expiresDays: Int? = null,
) : Route() {
- constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this(
+ constructor(key: ChatroomKey, message: String? = null, attachment: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this(
id = key.users.joinToString(","),
message = message,
+ attachment = attachment,
replyId = replyId,
draftId = draftId,
expiresDays = expiresDays,
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 4f0758c6b8..70c44937c0 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
@@ -71,8 +71,11 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes
@@ -215,6 +218,14 @@ open class CommentPostViewModel :
var wantsAnonymousPost by mutableStateOf(false)
+ // A single ephemeral signer reused for the whole compose session so that media
+ // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed
+ // by the same throwaway key, instead of leaking the real account's pubkey into the
+ // upload authorization (and therefore into the returned media URL).
+ private var anonymousSignerCache: NostrSigner? = null
+
+ fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it }
+
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -452,7 +463,7 @@ open class CommentPostViewModel :
cancel()
if (anonymous) {
- accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
+ accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
} else {
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
}
@@ -619,6 +630,7 @@ open class CommentPostViewModel :
context,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
+ forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
)
if (results.allGood) {
@@ -711,6 +723,7 @@ open class CommentPostViewModel :
wantsToAddGeoHash = false
wantsSecretEmoji = false
wantsAnonymousPost = false
+ anonymousSignerCache = null
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.clearText()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt
index aa67b6bf22..637f064cbe 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
fun ChatroomScreen(
roomId: ChatroomKey,
draftMessage: String? = null,
+ attachmentUri: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
expiresDays: Int? = null,
@@ -86,6 +87,7 @@ fun ChatroomScreen(
ChatroomView(
room = roomId,
draftMessage = draftMessage,
+ attachmentUri = attachmentUri,
replyToNote = replyToNote,
editFromDraft = editFromDraft,
expiresDays = expiresDays,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
index d01c90aba4..e3186e1657 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
@@ -35,10 +35,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
+import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@@ -55,6 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.utils.Log
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@@ -64,6 +67,7 @@ import kotlinx.coroutines.launch
fun ChatroomView(
room: ChatroomKey,
draftMessage: String?,
+ attachmentUri: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
expiresDays: Int? = null,
@@ -124,6 +128,14 @@ fun ChatroomView(
newPostModel.onMessageChanged()
}
}
+ val context = LocalContext.current
+ if (attachmentUri != null) {
+ LaunchedEffect(key1 = attachmentUri) {
+ resolveSharedMedia(context, attachmentUri)?.let {
+ newPostModel.pickedMedia(persistentListOf(it))
+ }
+ }
+ }
ChatroomViewUI(
room = room,
@@ -140,16 +152,15 @@ private const val PREFETCH_OLDER_MESSAGES = 3
/**
* Scroll-driven history loader for a conversation. The thread is reverse-laid-out (newest at the
* bottom, index 0), so older messages (and the load-more boundary) live at the highest indices. It
- * loads the next, older slice whenever the oldest end is in view — including a thread too short to
+ * loads the next, older page whenever the oldest end is in view — including a thread too short to
* scroll, so sitting at the start of a one-message chat keeps walking history back to its real
- * beginning (or until the window is exhausted). Each step is a bounded, one-shot slice that never
- * re-downloads, so walking a short thread is cheap per step — gift wraps can't be filtered per room,
- * so this advances the shared account-wide history window and the conversation's messages surface as
- * its slices are decrypted.
+ * beginning (or until both protocols are exhausted). Each step is a bounded `until`+`limit` page that
+ * never re-downloads, so walking a short thread is cheap per step — gift wraps can't be filtered per
+ * room, so this advances the shared account-wide history window and the conversation's messages
+ * surface as its pages are decrypted.
*
- * NIP-17 advances via [AccountGiftWrapsHistoryEoseManager.loadMore]; the NIP-04 follower
- * [ChatroomNip04HistorySubAssembler.reload] re-requests kind:4 at that same slice. The step is gated
- * on BOTH loaders being idle, so it never outruns the slower protocol, and stops once exhausted.
+ * Both protocols advance via their history managers' `loadMore`; the step is gated on BOTH loaders
+ * being idle, so it never outruns the slower one, and stops once both report exhausted.
*/
@Composable
private fun LoadOlderMessagesWhenScrolling(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
index 6c59988b49..a089688b21 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
@@ -114,9 +114,15 @@ fun PrivateMessageEditFieldRow(
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
+ // Rotate the draft tag only AFTER the async save completes. Doing it
+ // synchronously here (before launchSigner runs) would make sendDraftSync
+ // persist under a freshly-rotated tag, duplicating the draft. See the
+ // matching order in NewGroupDMScreen.
+ channelScreenModel.cancel()
}
+ } else {
+ channelScreenModel.cancel()
}
- channelScreenModel.cancel()
nav.popBack()
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt
new file mode 100644
index 0000000000..6a7a20842c
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.share
+
+import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
+
+/**
+ * Recent private-DM conversations only (no public channels, ephemeral chats, or
+ * marmot groups). Backs the Share-to-DM picker. Read-only/transient — extends the
+ * non-additive [FeedFilter] base because the picker loads once and does not need
+ * live additive updates.
+ */
+class ShareDMRoomsFeedFilter(
+ val account: Account,
+) : FeedFilter() {
+ override fun feedKey(): String = account.userProfile().pubkeyHex
+
+ override fun feed(): List {
+ val chatList = account.chatroomList
+ val followingKeySet = account.followingKeySet()
+
+ return chatList.rooms
+ .mapNotNull { key, chatroom ->
+ if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) &&
+ !account.isAllHidden(key.users)
+ ) {
+ chatroom.newestMessage
+ } else {
+ null
+ }
+ }.sortedWith(DefaultFeedOrder)
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt
new file mode 100644
index 0000000000..f88a4e936e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.share
+
+import androidx.compose.runtime.Stable
+import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.navigation.routes.Route
+
+/**
+ * Wraps an [INav] so that navigating to a chatroom ([Route.Room]) from the
+ * Share-to-DM picker carries the shared message and attachment into the composer.
+ * All other navigation behavior is delegated unchanged.
+ */
+@Stable
+class ShareToDMNav(
+ private val delegate: INav,
+ private val message: String?,
+ private val attachment: String?,
+) : INav by delegate {
+ override fun nav(route: Route) {
+ val rewritten = ShareToDMRouteRewriter.rewrite(route, message, attachment)
+ if (route is Route.Room) {
+ // One-shot: replace the picker in the back stack so backing out of the
+ // chat exits the share flow instead of returning to the picker, which
+ // would re-inject the shared text on re-tap and create duplicate drafts.
+ delegate.popUpTo(rewritten, Route.ShareToDM::class)
+ } else {
+ delegate.nav(rewritten)
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMRouteRewriter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMRouteRewriter.kt
new file mode 100644
index 0000000000..5f2743f48c
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMRouteRewriter.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.share
+
+import com.vitorpamplona.amethyst.ui.navigation.routes.Route
+
+/**
+ * Injects shared content into a chatroom navigation so that tapping a recent
+ * conversation in the Share-to-DM picker opens the composer pre-filled.
+ */
+object ShareToDMRouteRewriter {
+ fun rewrite(
+ route: Route,
+ message: String?,
+ attachment: String?,
+ ): Route =
+ if (route is Route.Room) {
+ route.copy(message = message, attachment = attachment)
+ } else {
+ route
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt
new file mode 100644
index 0000000000..4cf7f907a1
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt
@@ -0,0 +1,108 @@
+/*
+ * 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.share
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewModelScope
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
+import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.navigation.routes.Route
+import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShareToDMScreen(
+ message: String?,
+ attachment: String?,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ // Deliberately a screen-scoped, transient FeedContentState (not wired into
+ // AccountFeedContentStates like dmKnown/dmNew). The share picker is a one-shot,
+ // short-lived screen, so it owns its feed via viewModelScope and relies on
+ // WatchLifecycleAndUpdateModel to load/refresh on entry and resume rather than
+ // on the always-on additive update loop. Account switch recreates it (the
+ // remember key), which is correct for a transient picker.
+ val feedContentState =
+ remember(accountViewModel) {
+ FeedContentState(
+ ShareDMRoomsFeedFilter(accountViewModel.account),
+ accountViewModel.viewModelScope,
+ LocalCache,
+ )
+ }
+
+ val shareNav =
+ remember(nav, message, attachment) {
+ ShareToDMNav(nav, message, attachment)
+ }
+
+ WatchLifecycleAndUpdateModel(feedContentState)
+
+ Scaffold(
+ topBar = {
+ ShorterTopAppBar(title = { Text(stringRes(R.string.share_to_dm_title)) })
+ },
+ ) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding)) {
+ Text(
+ text = stringRes(R.string.share_to_dm_start_new),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(
+ role = Role.Button,
+ onClickLabel = stringRes(R.string.share_to_dm_start_new),
+ ) { nav.popUpTo(Route.NewGroupDM(message = message, attachment = attachment), Route.ShareToDM::class) }
+ .padding(16.dp),
+ )
+
+ HorizontalDivider(thickness = DividerThickness)
+
+ ChatroomListFeedView(
+ feedContentState = feedContentState,
+ scrollStateKey = "ShareToDM",
+ accountViewModel = accountViewModel,
+ nav = shareNav,
+ )
+ }
+ }
+}
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 990b02a7c5..150353320c 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
@@ -91,7 +91,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.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
@@ -306,6 +309,14 @@ open class ShortNotePostViewModel :
// Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false)
+ // A single ephemeral signer reused for the whole compose session so that media
+ // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed
+ // by the same throwaway key, instead of leaking the real account's pubkey into the
+ // upload authorization (and therefore into the returned media URL).
+ private var anonymousSignerCache: NostrSigner? = null
+
+ fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it }
+
// Scheduled posting: epoch seconds (UTC) when the post should be published.
// Null = post immediately on Send (existing behavior).
var scheduledForSec by mutableStateOf(null)
@@ -870,7 +881,7 @@ open class ShortNotePostViewModel :
}
if (anonymous) {
- accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
+ accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
// Tracked broadcasting with progress feedback (non-blocking)
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
@@ -1138,6 +1149,7 @@ open class ShortNotePostViewModel :
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
convertGifToMp4 = convertGifToMp4,
+ forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
)
if (results.allGood) {
@@ -1235,6 +1247,7 @@ open class ShortNotePostViewModel :
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
wantsAnonymousPost = false
+ anonymousSignerCache = null
scheduledForSec = null
forwardZapTo.value = SplitBuilder()
@@ -1467,6 +1480,7 @@ open class ShortNotePostViewModel :
account = account,
context = appContext,
useH265 = false,
+ forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
)
when (result) {
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 78a4b32799..853a9ed4cb 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1662,6 +1662,9 @@
Copy nprofile to clipboard
Copy npub to clipboard
Share or Save
+ Send as DM
+ Send to…
+ New message
Copy URL to clipboard
Copy Note ID to clipboard
Add Media to Gallery
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt
new file mode 100644
index 0000000000..9ffcdd761e
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.navigation
+
+import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ShareIntentRoutingTest {
+ @Test
+ fun detectsAliasByExactClassName() {
+ assertTrue(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
+ }
+
+ @Test
+ fun detectsAliasRegardlessOfPackagePrefix() {
+ // Flavors can change the resolved package prefix; match on the simple name.
+ assertTrue(ShareIntentRouting.isShareAsDm("com.example.fork.ui.ShareAsDMAlias"))
+ }
+
+ @Test
+ fun rejectsMainActivity() {
+ assertFalse(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.MainActivity"))
+ }
+
+ @Test
+ fun rejectsNull() {
+ assertFalse(ShareIntentRouting.isShareAsDm(null))
+ }
+
+ @Test
+ fun rejectsSuffixThatIsNotASimpleName() {
+ assertFalse(ShareIntentRouting.isShareAsDm("com.evil.XShareAsDMAlias"))
+ }
+}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareToDMRouteRewriterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareToDMRouteRewriterTest.kt
new file mode 100644
index 0000000000..1e03beb595
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareToDMRouteRewriterTest.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.navigation
+
+import com.vitorpamplona.amethyst.ui.navigation.routes.Route
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMRouteRewriter
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class ShareToDMRouteRewriterTest {
+ @Test
+ fun injectsMessageAndAttachmentIntoRoomRoute() {
+ val original = Route.Room(id = "pubkeyA,pubkeyB")
+ val result = ShareToDMRouteRewriter.rewrite(original, "hello", "content://media/1")
+
+ result as Route.Room
+ assertEquals("pubkeyA,pubkeyB", result.id)
+ assertEquals("hello", result.message)
+ assertEquals("content://media/1", result.attachment)
+ }
+
+ @Test
+ fun preservesExistingRoomFields() {
+ val original = Route.Room(id = "x", replyId = "reply1", expiresDays = 3)
+ val result = ShareToDMRouteRewriter.rewrite(original, "hi", null) as Route.Room
+
+ assertEquals("reply1", result.replyId)
+ assertEquals(3, result.expiresDays)
+ assertEquals("hi", result.message)
+ }
+
+ @Test
+ fun leavesNonRoomRoutesUnchanged() {
+ val original = Route.Home
+ val result = ShareToDMRouteRewriter.rewrite(original, "hi", "uri")
+ assertSame(original, result)
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt
new file mode 100644
index 0000000000..28984d4581
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.actions
+
+import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import com.vitorpamplona.quartz.nip10Notes.tags.notify
+
+/**
+ * Pure event-building "verbs" for kind:1 short-note replies (NIP-10).
+ *
+ * Builds a signed [TextNoteEvent] reply but does NOT publish it. The Amethyst
+ * Android UI flow does more than these builders — non-UI callers are
+ * responsible for the rest:
+ *
+ * * **Publish.** Hand the returned event to your relay client. Android uses
+ * `Account.sendMyPublicAndPrivateOutbox`, the desktop deck pipes through
+ * `dispatch(signed, localCache, relayManager)`, amy uses `Context.publish`.
+ * * **Writeable check.** Skip the call when the active signer is read-only
+ * (e.g. an npub-only login). Building will fail at the sign step otherwise.
+ * * **Parent kind.** Only kind:1 [TextNoteEvent] parents are well-defined here
+ * — replies to articles / comments belong on the NIP-22 path
+ * (`CommentEvent.replyBuilder`). Callers must filter; this signature enforces
+ * it via [EventHintBundle] of `TextNoteEvent`.
+ * * **Local cache update.** If your caller has a local event cache, feed the
+ * new event back in so the UI / next read sees the update without a relay
+ * round-trip.
+ *
+ * Canonical entry point for non-UI callers — the underlying
+ * [TextNoteEvent.build] reply-aware overload handles full NIP-10 tag carry:
+ * `marker=root` (parent's root e-tag if present, else parent.id),
+ * `marker=reply` (parent.id), the parent's full p-tag chain plus parent.pubKey,
+ * and the relay hint from [EventHintBundle].
+ */
+object ReplyActions {
+ /**
+ * Build a kind:1 [TextNoteEvent] that replies to [parent], wrapping it with
+ * NIP-10-correct marked e-tags and the parent's p-tag chain.
+ *
+ * Returns the signed event ready to be published. The reply preserves the
+ * parent's root reference so conformant clients can reconstruct the thread.
+ */
+ suspend fun replyTo(
+ parent: EventHintBundle,
+ content: String,
+ signer: NostrSigner,
+ ): TextNoteEvent {
+ // Per NIP-10, replies MUST carry the p-tags of the event being replied
+ // to plus the author's pubkey. TextNoteEvent.build(replyingTo=) only
+ // emits the e-tag chain — p-tag carry is the caller's responsibility.
+ val carriedPubKeys =
+ (parent.event.linkedPubKeys() + parent.event.pubKey)
+ .distinct()
+ .map { PTag(it, relayHint = null) }
+
+ val template =
+ TextNoteEvent.build(content, replyingTo = parent) {
+ notify(carriedPubKeys)
+ }
+ return signer.sign(template)
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/related/CompactNoteData.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/related/CompactNoteData.kt
new file mode 100644
index 0000000000..0c05649270
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/related/CompactNoteData.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.feeds.related
+
+import androidx.compose.runtime.Immutable
+
+/**
+ * Compact display data for a related content card.
+ * Marked @Immutable for Compose stability — all fields are val primitives/String.
+ */
+@Immutable
+data class CompactNoteData(
+ val id: String,
+ val title: String,
+ val authorName: String,
+ val thumbnailUrl: String?,
+ val zapCount: String,
+)
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt
new file mode 100644
index 0000000000..e602464be5
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.actions
+
+import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
+import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
+import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
+import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class ReplyActionsTest {
+ private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
+ private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
+
+ private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008"
+ private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray()))
+
+ @Test
+ fun replyToTopLevelParent_setsRootToParentAndCarriesAuthor() =
+ runTest {
+ // Alice posts a top-level note (no e-tags = parent IS its own root).
+ val parent = aliceSigner.sign(TextNoteEvent.build("hello"))
+ assertTrue(parent.isNewThread(), "parent must be a fresh thread for this case")
+
+ // Bob replies.
+ val reply = ReplyActions.replyTo(EventHintBundle(parent, null), "hi alice", bobSigner)
+
+ assertEquals(TextNoteEvent.KIND, reply.kind)
+ assertEquals(bobSigner.pubKey, reply.pubKey)
+
+ // Per `prepareETagsAsReplyTo`: when parent has no root, only a ROOT
+ // marker is emitted (it doubles as the reply target). No separate
+ // REPLY marker. `markedReplyTos()` should still resolve to parent.id.
+ val root = reply.markedRoot()
+ assertNotNull(root, "reply must carry a NIP-10 root marker")
+ assertEquals(parent.id, root.eventId, "root marker must point at the top-level parent")
+
+ // p-tag carry must include the parent's author so they're notified.
+ val pubKeys = reply.tags.mapNotNull(PTag::parseKey)
+ assertTrue(parent.pubKey in pubKeys, "reply must carry the parent's pubkey in p-tags")
+ }
+
+ @Test
+ fun replyToDeepThread_carriesRootForwardAndChainsPTags() =
+ runTest {
+ // Build A (root) → B (alice's reply to A) → C (carol's reply to B).
+ val a = aliceSigner.sign(TextNoteEvent.build("the original"))
+
+ val carolPriv = "0000000000000000000000000000000000000000000000000000000000000009"
+ val carolSigner = NostrSignerInternal(KeyPair(carolPriv.hexToByteArray()))
+
+ val b = ReplyActions.replyTo(EventHintBundle(a, null), "good point", aliceSigner)
+
+ // C replies to B — must carry A as root (not B), and reply to B.
+ val c = ReplyActions.replyTo(EventHintBundle(b, null), "agreed", carolSigner)
+
+ val rootC = c.markedRoot()
+ assertNotNull(rootC, "deep reply must carry root marker")
+ assertEquals(a.id, rootC.eventId, "deep reply's root must chain through to original")
+
+ val replyC = c.markedReply()
+ assertNotNull(replyC, "deep reply must carry reply marker")
+ assertEquals(b.id, replyC.eventId, "deep reply's reply marker must point at immediate parent")
+
+ // p-tag chain: must include both alice (root author / parent author) and parent.pubKey.
+ val pubKeys = c.tags.mapNotNull(PTag::parseKey).toSet()
+ assertTrue(aliceSigner.pubKey in pubKeys, "deep reply must carry root author in p-tags")
+ }
+
+ @Test
+ fun replyEvent_isSignedAndKind1() =
+ runTest {
+ val parent = aliceSigner.sign(TextNoteEvent.build("seed"))
+ val reply = ReplyActions.replyTo(EventHintBundle(parent, null), "thanks", bobSigner)
+
+ assertEquals(TextNoteEvent.KIND, reply.kind)
+ assertTrue(reply.id.length == 64, "reply id must be a 32-byte hex")
+ assertTrue(reply.sig.length == 128, "reply must be signed (64-byte sig hex)")
+ assertEquals("thanks", reply.content)
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
index d1548c194e..eb6b9f7634 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
@@ -1459,6 +1459,7 @@ fun MainContent(
}
},
activeColumnType = activeColumnType,
+ feedTabActive = activeColumnType is DeckColumnType.HomeFeed,
onShowImportFollowListDialog = onShowImportFollowListDialog,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
index d0feee80c2..be1a200191 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
@@ -441,10 +441,14 @@ class DesktopLocalCache : ICacheProvider {
*/
private var lastContactListCreatedAt = 0L
+ var lastContactListEvent: ContactListEvent? = null
+ private set
+
private fun consumeContactList(event: ContactListEvent): Boolean {
// Replaceable event — only accept newer contact lists
if (event.createdAt <= lastContactListCreatedAt) return false
lastContactListCreatedAt = event.createdAt
+ lastContactListEvent = event
_followedUsers.value = event.verifiedFollowKeySet()
return true
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt
new file mode 100644
index 0000000000..06a9c51f15
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.desktop.cache
+
+import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
+import com.vitorpamplona.quartz.nip01Core.core.Event
+
+/**
+ * Canonical local-first dispatch for user-action events on desktop: write to
+ * the local cache before broadcasting so the UI reflects the action immediately,
+ * even if relay round-trips fail.
+ *
+ * Replaces five inlined `consume + broadcastToAll` couplets that had drifted
+ * in ordering (reactions/follows did broadcast-then-consume, replies did
+ * consume-then-broadcast). Use this everywhere a signed event must be both
+ * persisted locally and pushed to outbox relays.
+ */
+fun dispatch(
+ signed: Event,
+ localCache: DesktopLocalCache,
+ relayManager: RelayConnectionManager,
+) {
+ localCache.consume(signed, relay = null)
+ relayManager.broadcastToAll(signed)
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
index 046cb72d75..ff25760a79 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
@@ -82,9 +82,12 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.actions.ReplyActions
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
+import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
@@ -96,10 +99,12 @@ import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout
+import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
+import com.vitorpamplona.amethyst.desktop.cache.dispatch
import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
@@ -115,6 +120,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscrip
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
+import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
@@ -122,10 +128,17 @@ import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
+import com.vitorpamplona.amethyst.desktop.ui.thread.CommentItem
+import com.vitorpamplona.amethyst.desktop.ui.thread.CommentsCard
+import com.vitorpamplona.amethyst.desktop.ui.thread.InlineReplyInput
+import com.vitorpamplona.amethyst.desktop.ui.thread.RelatedContentSection
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
+import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -137,6 +150,9 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
data class LightboxState(
val urls: List,
@@ -165,6 +181,9 @@ fun FeedNoteCard(
onImageClick: ((List, Int) -> Unit)? = null,
onMediaClick: ((List, Int, Float) -> Unit)? = null,
onHashtagClick: ((String) -> Unit)? = null,
+ followedUsers: Set = emptySet(),
+ myPubKeyHex: String? = null,
+ onFollow: ((String) -> Unit)? = null,
) {
val event = note.event ?: return
val isRepost = event is RepostEvent || event is GenericRepostEvent
@@ -226,8 +245,13 @@ fun FeedNoteCard(
BoostedMark()
}
- // Original note content
+ // Original note content with actions inside card
val displayData = remember(originalEvent, metadataState) { originalEvent.toNoteDisplayData(localCache) }
+ val showRepostFollowPill =
+ account != null &&
+ onFollow != null &&
+ originalEvent.pubKey != myPubKeyHex &&
+ originalEvent.pubKey !in followedUsers
NoteCard(
note = displayData,
modifier = Modifier.fillMaxWidth(),
@@ -238,30 +262,41 @@ fun FeedNoteCard(
onHashtagClick = onHashtagClick,
onImageClick = onImageClick,
onMediaClick = onMediaClick,
+ headerTrailingContent =
+ if (showRepostFollowPill) {
+ {
+ FollowPill(onClick = { onFollow.invoke(originalEvent.pubKey) })
+ }
+ } else {
+ null
+ },
+ bottomContent =
+ if (account != null) {
+ {
+ NoteActionsRow(
+ event = originalEvent,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onReplyClick = onReply,
+ onZapFeedback = onZapFeedback,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ note = originalNote,
+ zapCount = originalNote.zaps.size,
+ zapAmountSats = zapAmount.toLong(),
+ zapReceipts = emptyList(),
+ reactionCount = reactionCount,
+ replyCount = replyCount,
+ repostCount = repostCount,
+ onNavigateToThread = onNavigateToThread,
+ onNavigateToProfile = onNavigateToProfile,
+ )
+ }
+ } else {
+ null
+ },
)
-
- // Action buttons for original note
- if (account != null) {
- NoteActionsRow(
- event = originalEvent,
- relayManager = relayManager,
- localCache = localCache,
- account = account,
- nwcConnection = nwcConnection,
- onReplyClick = onReply,
- onZapFeedback = onZapFeedback,
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
- note = originalNote,
- zapCount = originalNote.zaps.size,
- zapAmountSats = zapAmount.toLong(),
- zapReceipts = emptyList(),
- reactionCount = reactionCount,
- replyCount = replyCount,
- repostCount = repostCount,
- onNavigateToThread = onNavigateToThread,
- onNavigateToProfile = onNavigateToProfile,
- )
- }
}
} else {
// Regular note rendering
@@ -280,42 +315,57 @@ fun FeedNoteCard(
onDispose { note.clearFlow() }
}
- Column {
- val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) }
- NoteCard(
- note = displayData,
- modifier = Modifier.fillMaxWidth(),
- localCache = localCache,
- onClick = { onNavigateToThread(event.id) },
- onAuthorClick = onNavigateToProfile,
- onMentionClick = onNavigateToProfile,
- onHashtagClick = onHashtagClick,
- onImageClick = onImageClick,
- onMediaClick = onMediaClick,
- )
-
- if (account != null) {
- NoteActionsRow(
- event = event,
- relayManager = relayManager,
- localCache = localCache,
- account = account,
- nwcConnection = nwcConnection,
- onReplyClick = onReply,
- onZapFeedback = onZapFeedback,
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
- note = note,
- zapCount = note.zaps.size,
- zapAmountSats = zapAmount.toLong(),
- zapReceipts = emptyList(),
- reactionCount = reactionCount,
- replyCount = replyCount,
- repostCount = repostCount,
- onNavigateToThread = onNavigateToThread,
- onNavigateToProfile = onNavigateToProfile,
- )
- }
- }
+ val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) }
+ val showFollowPill =
+ account != null &&
+ onFollow != null &&
+ event.pubKey != myPubKeyHex &&
+ event.pubKey !in followedUsers
+ NoteCard(
+ note = displayData,
+ modifier = Modifier.fillMaxWidth(),
+ localCache = localCache,
+ onClick = { onNavigateToThread(event.id) },
+ onAuthorClick = onNavigateToProfile,
+ onMentionClick = onNavigateToProfile,
+ onHashtagClick = onHashtagClick,
+ onImageClick = onImageClick,
+ onMediaClick = onMediaClick,
+ headerTrailingContent =
+ if (showFollowPill) {
+ {
+ FollowPill(onClick = { onFollow.invoke(event.pubKey) })
+ }
+ } else {
+ null
+ },
+ bottomContent =
+ if (account != null) {
+ {
+ NoteActionsRow(
+ event = event,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ onReplyClick = onReply,
+ onZapFeedback = onZapFeedback,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ note = note,
+ zapCount = note.zaps.size,
+ zapAmountSats = zapAmount.toLong(),
+ zapReceipts = emptyList(),
+ reactionCount = reactionCount,
+ replyCount = replyCount,
+ repostCount = repostCount,
+ onNavigateToThread = onNavigateToThread,
+ onNavigateToProfile = onNavigateToProfile,
+ )
+ }
+ } else {
+ null
+ },
+ )
}
}
@@ -356,6 +406,25 @@ fun FeedScreen(
var replyToEvent by remember { mutableStateOf(null) }
var lightboxState by remember { mutableStateOf(null) }
+
+ // Inline expansion state — which note is expanded to show comments + related
+ var expandedNoteId by remember { mutableStateOf(null) }
+
+ // Follow pill state
+ val scope = rememberCoroutineScope()
+ val followMutex = remember { Mutex() }
+ val onFollowFromFeed: (String) -> Unit = { pubKeyHex ->
+ if (account != null) {
+ scope.launch(Dispatchers.IO) {
+ followMutex.withLock {
+ val currentList = localCache.lastContactListEvent
+ val updatedEvent = FollowAction.follow(pubKeyHex, account.signer, currentList)
+ // consume updates followedUsers StateFlow + stores the event before broadcast
+ dispatch(updatedEvent, localCache, relayManager)
+ }
+ }
+ }
+ }
var showRelayPicker by remember { mutableStateOf(false) }
var activeFeedId by remember { mutableStateOf(customFeedId) }
var activeFeedSource by remember {
@@ -465,7 +534,7 @@ fun FeedScreen(
// Force refresh when followedUsers arrives and feed is still empty
LaunchedEffect(followedUsers, feedState) {
if (followedUsers.isNotEmpty() && feedState is FeedState.Empty) {
- kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ withContext(Dispatchers.IO) {
viewModel.feedState.refreshSuspended()
}
}
@@ -686,6 +755,8 @@ fun FeedScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(loadedState.list, key = { it.idHex }) { note ->
+ val isExpanded = note.idHex == expandedNoteId
+
FeedNoteCard(
note = note,
relayManager = relayManager,
@@ -695,7 +766,10 @@ fun FeedScreen(
onReply = { replyToEvent = note.event },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
- onNavigateToThread = onNavigateToThread,
+ onNavigateToThread = { noteId ->
+ // Toggle inline expansion instead of navigating
+ expandedNoteId = if (expandedNoteId == noteId) null else noteId
+ },
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
@@ -705,7 +779,33 @@ fun FeedScreen(
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
+ followedUsers = followedUsers,
+ myPubKeyHex = account?.pubKeyHex,
+ onFollow = onFollowFromFeed,
)
+
+ // Inline expanded content: CommentsCard + Related
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ ExpandedNoteContent(
+ note = note,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ onNavigateToProfile = onNavigateToProfile,
+ onNavigateToThread = { noteId ->
+ expandedNoteId = if (expandedNoteId == noteId) null else noteId
+ },
+ onNavigateToThreadOverlay = onNavigateToThread,
+ onReply = { replyToEvent = it },
+ onZapFeedback = onZapFeedback,
+ )
+ }
}
}
}
@@ -1376,3 +1476,202 @@ private fun FeedHeader(
}
}
}
+
+@Composable
+private fun ExpandedNoteContent(
+ note: com.vitorpamplona.amethyst.commons.model.Note,
+ relayManager: DesktopRelayConnectionManager,
+ localCache: DesktopLocalCache,
+ account: AccountState.LoggedIn?,
+ nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
+ subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
+ onNavigateToProfile: (String) -> Unit = {},
+ onNavigateToThread: (String) -> Unit = {},
+ onNavigateToThreadOverlay: (String) -> Unit = {},
+ onReply: (Event) -> Unit = {},
+ onZapFeedback: (ZapFeedback) -> Unit = {},
+) {
+ val event = note.event ?: return
+ val noteId = event.id
+ val expandedScope = rememberCoroutineScope()
+ val connectedRelays =
+ relayManager.relayStatuses
+ .collectAsState()
+ .value.keys
+
+ // Subscribe for replies when expanded
+ rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
+ if (connectedRelays.isNotEmpty()) {
+ createThreadRepliesSubscription(
+ relays = connectedRelays,
+ noteId = noteId,
+ onEvent = { ev, _, relay, _ ->
+ subscriptionsCoordinator?.consumeEvent(ev, relay)
+ },
+ onEose = { _, _ -> },
+ )
+ } else {
+ null
+ }
+ }
+
+ // Observe replies flow so we recompose when new replies arrive
+ val noteFlowSet = remember(note) { note.flow() }
+ val repliesState by noteFlowSet.replies.stateFlow.collectAsState()
+
+ DisposableEffect(note) { onDispose { note.clearFlow() } }
+
+ // Get reply notes from cache — recompute when replies change
+ val replyNotes = remember(repliesState) { note.replies.sortedByDescending { it.createdAt() } }
+
+ // Load metadata for reply authors
+ LaunchedEffect(replyNotes, subscriptionsCoordinator) {
+ if (subscriptionsCoordinator != null && replyNotes.isNotEmpty()) {
+ val authors = replyNotes.mapNotNull { it.event?.pubKey }.distinct()
+ if (authors.isNotEmpty()) {
+ subscriptionsCoordinator.loadMetadataBatched(authors)
+ }
+ }
+ }
+
+ Column(modifier = Modifier.padding(top = 8.dp)) {
+ // Comments card
+ CommentsCard(
+ commentCount = replyNotes.size,
+ replyContent = {
+ if (account != null) {
+ val myUser = remember(account.pubKeyHex) { localCache.getUserIfExists(account.pubKeyHex) }
+ val myAvatarUrl = remember(myUser) { myUser?.profilePicture() }
+
+ InlineReplyInput(
+ myAvatarUrl = myAvatarUrl,
+ onSend = { content ->
+ withContext(Dispatchers.IO) {
+ val parentText = event as? TextNoteEvent ?: return@withContext
+ val signedEvent =
+ ReplyActions.replyTo(
+ EventHintBundle(parentText, null),
+ content,
+ account.signer,
+ )
+ dispatch(signedEvent, localCache, relayManager)
+ }
+ },
+ )
+ }
+ },
+ ) {
+ if (replyNotes.isEmpty()) {
+ Text(
+ "No replies yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ } else {
+ replyNotes.take(5).forEachIndexed { index, replyNote ->
+ val replyEvent = replyNote.event
+ val flowSet = remember(replyNote) { replyNote.flow() }
+ val metadataState by flowSet.metadata.stateFlow.collectAsState()
+ val reactionsState by flowSet.reactions.stateFlow.collectAsState()
+ val zapsState by flowSet.zaps.stateFlow.collectAsState()
+
+ DisposableEffect(replyNote) { onDispose { replyNote.clearFlow() } }
+
+ val author =
+ remember(replyEvent?.pubKey, metadataState) {
+ replyEvent?.pubKey?.let { localCache.getUserIfExists(it) }
+ }
+ val reactionCount = remember(reactionsState) { replyNote.countReactions() }
+ val zapAmount = remember(zapsState) { replyNote.zapsAmount }
+
+ CommentItem(
+ authorName = author?.toBestDisplayName() ?: replyEvent?.pubKey?.take(8) ?: "",
+ authorHandle = author?.pubkeyNpub()?.take(16)?.let { "@$it..." } ?: "",
+ authorAvatarUrl = author?.profilePicture(),
+ authorPubKeyHex = replyEvent?.pubKey ?: "",
+ content = replyEvent?.content ?: "",
+ timeAgo = (replyEvent?.createdAt ?: 0L).toTimeAgo(),
+ reactionCount = reactionCount,
+ zapAmount = zapAmount.toLong(),
+ onReply = { replyNote.event?.let { onReply(it) } },
+ onLike = {
+ val ev = replyNote.event
+ if (account != null && ev != null) {
+ expandedScope.launch(Dispatchers.IO) {
+ val signed =
+ ReactionAction.reactTo(
+ EventHintBundle(ev, null),
+ "+",
+ account.signer,
+ )
+ dispatch(signed, localCache, relayManager)
+ }
+ }
+ },
+ onZap = {
+ val ev = replyNote.event
+ if (account != null && ev != null && nwcConnection != null) {
+ expandedScope.launch {
+ val feedback =
+ zapNote(
+ event = ev,
+ account = account,
+ relayManager = relayManager,
+ localCache = localCache,
+ amountSats = 21,
+ nwcConnection = nwcConnection,
+ )
+ onZapFeedback(feedback)
+ }
+ }
+ },
+ onAuthorClick = { replyNote.event?.pubKey?.let { onNavigateToProfile(it) } },
+ )
+ if (index < replyNotes.take(5).lastIndex) {
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+ }
+ }
+
+ // Related content
+ val noteHashtags =
+ remember(event) {
+ event.tags.mapNotNull(HashtagTag::parse).toSet()
+ }
+ RelatedContentSection(
+ noteId = noteId,
+ authorPubKey = event.pubKey,
+ noteHashtags = noteHashtags,
+ localCache = localCache,
+ onItemClick = onNavigateToThreadOverlay,
+ onViewAll = { onNavigateToProfile(event.pubKey) },
+ )
+ }
+}
+
+@Composable
+private fun FollowPill(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FilterChip(
+ selected = false,
+ onClick = onClick,
+ label = {
+ Text(
+ "Follow",
+ style = MaterialTheme.typography.labelSmall,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ MaterialSymbols.PersonAdd,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ )
+ },
+ modifier = modifier.height(28.dp),
+ )
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
index fd31ba84b6..83806d8395 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
@@ -86,19 +86,20 @@ import com.vitorpamplona.amethyst.commons.model.nip51Bookmarks.BookmarkAction
import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction
import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
+import com.vitorpamplona.amethyst.commons.util.toZapAmount
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
+import com.vitorpamplona.amethyst.desktop.ui.note.ShareMenu
+import com.vitorpamplona.amethyst.desktop.ui.note.rememberShareMenuState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
-import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
-import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -106,8 +107,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
-import java.awt.Toolkit
-import java.awt.datatransfer.StringSelection
import kotlin.coroutines.resume
private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)
@@ -234,7 +233,7 @@ fun ZapAmountDialog(
FilterChip(
selected = selectedAmount == amount,
onClick = { selectedAmount = amount },
- label = { Text(formatSats(amount)) },
+ label = { Text(amount.toZapAmount()) },
)
}
}
@@ -252,7 +251,7 @@ fun ZapAmountDialog(
},
confirmButton = {
Button(onClick = { onZap(selectedAmount, message) }) {
- Text("Zap ${formatSats(selectedAmount)} sats")
+ Text("Zap ${selectedAmount.toZapAmount()} sats")
}
},
dismissButton = {
@@ -263,8 +262,6 @@ fun ZapAmountDialog(
)
}
-private fun formatSats(amount: Long): String = if (amount >= 1000) "${amount / 1000}k" else "$amount"
-
/**
* Dialog for choosing bookmark visibility (public or private).
*/
@@ -371,7 +368,7 @@ fun ZapReceiptsDialog(
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
- Text("${formatSats(totalAmount)} sats")
+ Text("${totalAmount.toZapAmount()} sats")
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
@@ -413,7 +410,7 @@ fun ZapReceiptsDialog(
}
}
Text(
- text = "${formatSats(receipt.amountSats)} sats",
+ text = "${receipt.amountSats.toZapAmount()} sats",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
@@ -528,7 +525,7 @@ fun ZapReceiptsPopup(
modifier = Modifier.size(16.dp),
)
Text(
- "${formatSats(totalSats)} sats",
+ "${totalSats.toZapAmount()} sats",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
@@ -569,7 +566,7 @@ fun ZapReceiptsPopup(
}
}
Text(
- text = "${formatSats(entry.amount)} sats",
+ text = "${entry.amount.toZapAmount()} sats",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
@@ -1231,7 +1228,7 @@ fun NoteActionsRow(
}
if (zapAmountSats > 0) {
Text(
- text = formatSats(zapAmountSats),
+ text = zapAmountSats.toZapAmount(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { showZapReceiptsDialog = true },
@@ -1312,56 +1309,25 @@ fun NoteActionsRow(
)
}
- // Overflow menu (three dots)
- var showOverflowMenu by remember { mutableStateOf(false) }
+ // Share menu
+ val shareMenuState = rememberShareMenuState()
Box {
IconButton(
- onClick = { showOverflowMenu = true },
+ onClick = { shareMenuState.open() },
modifier = Modifier.size(32.dp),
) {
Icon(
- MaterialSymbols.MoreVert,
- contentDescription = "More options",
+ MaterialSymbols.Share,
+ contentDescription = "Share",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
- DropdownMenu(
- expanded = showOverflowMenu,
- onDismissRequest = { showOverflowMenu = false },
- ) {
- DropdownMenuItem(
- text = { Text("Copy Note Link") },
- onClick = {
- val noteLink = "nostr:${NNote.create(event.id)}"
- copyToClipboard(noteLink)
- showOverflowMenu = false
- },
- )
- DropdownMenuItem(
- text = { Text("Copy Event Link") },
- onClick = {
- val relays = relayManager.connectedRelays.value.take(3)
- val neventLink = "nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}"
- copyToClipboard(neventLink)
- showOverflowMenu = false
- },
- )
- DropdownMenuItem(
- text = { Text("Copy Event ID") },
- onClick = {
- copyToClipboard(event.id)
- showOverflowMenu = false
- },
- )
- DropdownMenuItem(
- text = { Text("Copy Raw JSON") },
- onClick = {
- copyToClipboard(event.toJson())
- showOverflowMenu = false
- },
- )
- }
+ ShareMenu(
+ state = shareMenuState,
+ event = event,
+ relayManager = relayManager,
+ )
}
}
@@ -1516,7 +1482,7 @@ private suspend fun repostNote(
* Creates a zap request and pays via NWC or opens external wallet.
* Returns feedback for UI display.
*/
-private suspend fun zapNote(
+internal suspend fun zapNote(
event: Event,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
@@ -1693,11 +1659,3 @@ private suspend fun fetchUserLightningAddress(
relayManager.unsubscribe(subId)
}
}
-
-/**
- * Copies text to the system clipboard.
- */
-private fun copyToClipboard(text: String) {
- val clipboard = Toolkit.getDefaultToolkit().systemClipboard
- clipboard.setContents(StringSelection(text), null)
-}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt
index c1878269a0..78f8b224d4 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -46,20 +44,24 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.actions.ReplyActions
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
-import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
+import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
+import com.vitorpamplona.amethyst.desktop.cache.dispatch
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -70,11 +72,21 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubsc
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
+import com.vitorpamplona.amethyst.desktop.ui.thread.CommentItem
+import com.vitorpamplona.amethyst.desktop.ui.thread.CommentsCard
+import com.vitorpamplona.amethyst.desktop.ui.thread.InlineReplyInput
+import com.vitorpamplona.amethyst.desktop.ui.thread.RelatedContentSection
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
+import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
/**
* Desktop Thread Screen - displays a note and all its replies in a thread view.
@@ -98,6 +110,7 @@ fun ThreadScreen(
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays = relayStatuses.keys
+ val threadScope = rememberCoroutineScope()
// Lightbox state
var lightboxState by remember { mutableStateOf(null) }
@@ -311,54 +324,141 @@ fun ThreadScreen(
}
}
- // Reply notes with level indicators
- items(replyNotes, key = { it.idHex }) { note ->
- val level = calculateLevel(note)
- Column(
- modifier =
- Modifier
- .drawReplyLevel(
- level = level,
- color = MaterialTheme.colorScheme.outlineVariant,
- selected = MaterialTheme.colorScheme.outlineVariant,
- ).clickable {
- note.event?.let { onNavigateToThread(it.id) }
- },
+ // Comments card (replies + inline reply input)
+ item(key = "comments-card") {
+ Spacer(Modifier.height(12.dp))
+ CommentsCard(
+ commentCount = replyNotes.size,
+ replyContent = {
+ if (account != null && rootNote != null) {
+ val myPubKey = account.pubKeyHex
+ val myUser =
+ remember(myPubKey) { localCache.getUserIfExists(myPubKey) }
+ val myAvatarUrl = remember(myUser) { myUser?.profilePicture() }
+
+ InlineReplyInput(
+ myAvatarUrl = myAvatarUrl,
+ onSend = { content ->
+ withContext(Dispatchers.IO) {
+ val parentText =
+ rootNote.event as? TextNoteEvent
+ ?: return@withContext
+ val signedEvent =
+ ReplyActions.replyTo(
+ EventHintBundle(parentText, null),
+ content,
+ account.signer,
+ )
+ dispatch(signedEvent, localCache, relayManager)
+ }
+ },
+ )
+ }
+ },
) {
- FeedNoteCard(
- note = note,
- relayManager = relayManager,
- localCache = localCache,
- account = account,
- nwcConnection = nwcConnection,
- onReply = { note.event?.let { onReply(it) } },
- onZapFeedback = onZapFeedback,
- onNavigateToProfile = onNavigateToProfile,
- onNavigateToThread = onNavigateToThread,
- onImageClick = { urls, index ->
- lightboxState = LightboxState(urls, index)
- },
- onMediaClick = { urls, index, seekPos ->
- com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
- .playVideo(urls[index], seekPos)
- com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
- .toggleFullscreen()
- },
- )
+ if (replyNotes.isEmpty()) {
+ Text(
+ "No replies yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ } else {
+ replyNotes.forEachIndexed { index, note ->
+ val event = note.event
+
+ // Observe metadata + reactions so we recompose
+ // when author info arrives from relays
+ val flowSet = remember(note) { note.flow() }
+ val metadataState by flowSet.metadata.stateFlow.collectAsState()
+ val reactionsState by flowSet.reactions.stateFlow.collectAsState()
+ val zapsState by flowSet.zaps.stateFlow.collectAsState()
+
+ DisposableEffect(note) { onDispose { note.clearFlow() } }
+
+ val author =
+ remember(event?.pubKey, metadataState) {
+ event?.pubKey?.let { localCache.getUserIfExists(it) }
+ }
+
+ val reactionCount =
+ remember(reactionsState) { note.countReactions() }
+ val zapAmount = remember(zapsState) { note.zapsAmount }
+
+ CommentItem(
+ authorName =
+ author?.toBestDisplayName()
+ ?: event?.pubKey?.take(8)
+ ?: "",
+ authorHandle =
+ author?.pubkeyNpub()?.take(16)?.let { "@$it..." }
+ ?: "",
+ authorAvatarUrl = author?.profilePicture(),
+ authorPubKeyHex = event?.pubKey ?: "",
+ content = event?.content ?: "",
+ timeAgo = (event?.createdAt ?: 0L).toTimeAgo(),
+ reactionCount = reactionCount,
+ zapAmount = zapAmount.toLong(),
+ onReply = { note.event?.let { onReply(it) } },
+ onLike = {
+ val ev = note.event
+ if (account != null && ev != null) {
+ threadScope.launch(Dispatchers.IO) {
+ val signed =
+ ReactionAction.reactTo(
+ EventHintBundle(ev, null),
+ "+",
+ account.signer,
+ )
+ dispatch(signed, localCache, relayManager)
+ }
+ }
+ },
+ onZap = {
+ val ev = note.event
+ if (account != null && ev != null && nwcConnection != null) {
+ threadScope.launch {
+ zapNote(
+ event = ev,
+ account = account,
+ relayManager = relayManager,
+ localCache = localCache,
+ amountSats = 21,
+ nwcConnection = nwcConnection,
+ )
+ }
+ }
+ },
+ onAuthorClick = {
+ note.event?.pubKey?.let { onNavigateToProfile(it) }
+ },
+ )
+ if (index < replyNotes.lastIndex) {
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+ }
}
- HorizontalDivider(thickness = 1.dp)
}
- // Empty/loading state for replies
- if (replyNotes.isEmpty()) {
- item {
- Spacer(Modifier.height(32.dp))
- Text(
- "No replies yet",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(16.dp),
- )
+ // Related content section
+ if (rootNote != null) {
+ item(key = "related-content") {
+ val rootEvent = rootNote.event
+ if (rootEvent != null) {
+ val noteHashtags =
+ remember(rootEvent) {
+ rootEvent.tags.hashtags().toSet()
+ }
+ RelatedContentSection(
+ noteId = noteId,
+ authorPubKey = rootEvent.pubKey,
+ noteHashtags = noteHashtags,
+ localCache = localCache,
+ onItemClick = onNavigateToThread,
+ onViewAll = { onNavigateToProfile(rootEvent.pubKey) },
+ )
+ }
}
}
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt
index 86d23cf80f..90f0dbce05 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt
@@ -20,6 +20,14 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
@@ -29,11 +37,22 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
@@ -65,25 +84,38 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
class ColumnNavigationState {
- private val _stack = MutableStateFlow>(emptyList())
- val stack: kotlinx.coroutines.flow.StateFlow> = _stack.asStateFlow()
+ private val _stack = mutableStateListOf()
+ val stack: List get() = _stack
+ val current: DesktopScreen? get() = _stack.lastOrNull()
+ val hasBackStack: Boolean get() = _stack.isNotEmpty()
+
+ var navigatingForward by mutableStateOf(true)
+ private set
+
+ fun pushWithCap(
+ screen: DesktopScreen,
+ maxDepth: Int = 2,
+ ) {
+ navigatingForward = true
+ if (_stack.size >= maxDepth) _stack.removeFirst()
+ _stack.add(screen)
+ }
fun push(screen: DesktopScreen) {
- _stack.value = _stack.value + screen
+ pushWithCap(screen)
}
fun pop(): Boolean {
- if (_stack.value.isEmpty()) return false
- _stack.value = _stack.value.dropLast(1)
+ if (_stack.isEmpty()) return false
+ navigatingForward = false
+ _stack.removeLast()
return true
}
fun clear() {
- _stack.value = emptyList()
+ _stack.clear()
}
}
@@ -111,19 +143,38 @@ fun DeckColumnContainer(
modifier: Modifier = Modifier,
) {
val navState = remember(column.id) { ColumnNavigationState() }
- val navStack by navState.stack.collectAsState()
- val currentOverlay = navStack.lastOrNull()
+ val currentOverlay = navState.current
+ val focusRequester = remember { FocusRequester() }
+
+ // Request focus once when the column is created. Re-keying on
+ // `currentOverlay` would steal focus from sibling columns whenever any
+ // deck column mutates its overlay state (e.g. typing in column A's reply
+ // box loses focus when column B opens a profile). Esc continues to work
+ // because the column still owns focus when the user hits the key.
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
Column(
modifier =
modifier
.width(column.width.dp)
- .fillMaxHeight(),
+ .fillMaxHeight()
+ .focusRequester(focusRequester)
+ .focusable()
+ .onPreviewKeyEvent { event ->
+ if (event.key == Key.Escape && event.type == KeyEventType.KeyUp && navState.hasBackStack) {
+ navState.pop()
+ true
+ } else {
+ false
+ }
+ },
) {
ColumnHeader(
column = column,
canClose = canClose,
- hasBackStack = navStack.isNotEmpty(),
+ hasBackStack = navState.hasBackStack,
onBack = { navState.pop() },
onClose = onClose,
onDoubleClick = onDoubleClickHeader,
@@ -147,10 +198,8 @@ fun DeckColumnContainer(
)
// Content runs edge-to-edge; each screen adds its own header padding
- // to match the Messages pattern (padding(horizontal = 12, vertical = 8)
- // on the title row, no outer wrapper).
Box(modifier = Modifier.fillMaxSize()) {
- // Always keep RootContent composed so state (e.g. search results) survives navigation
+ // Always keep RootContent composed so state survives navigation
RootContent(
columnType = column.type,
relayManager = relayManager,
@@ -174,28 +223,75 @@ fun DeckColumnContainer(
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
onNavigateToRelays = onNavigateToRelays,
)
- if (currentOverlay != null) {
- Surface(
- color = MaterialTheme.colorScheme.background,
- modifier = Modifier.fillMaxSize(),
- ) {
- OverlayContent(
- screen = currentOverlay,
- relayManager = relayManager,
- localCache = localCache,
- account = account,
- nwcConnection = nwcConnection,
- subscriptionsCoordinator = subscriptionsCoordinator,
- highlightStore = highlightStore,
- draftStore = draftStore,
- onShowComposeDialog = onShowComposeDialog,
- onShowReplyDialog = onShowReplyDialog,
- onZapFeedback = onZapFeedback,
- onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
- onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
- onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
- onBack = { navState.pop() },
- )
+
+ // Overlay with slide animation
+ AnimatedContent(
+ targetState = currentOverlay,
+ transitionSpec = {
+ val duration = 200
+ if (navState.navigatingForward) {
+ (
+ slideInHorizontally(
+ tween(duration),
+ ) { it } +
+ fadeIn(
+ androidx.compose.animation.core
+ .tween(duration),
+ )
+ ).togetherWith(
+ slideOutHorizontally(
+ tween(duration),
+ ) { -it } +
+ fadeOut(
+ androidx.compose.animation.core
+ .tween(duration),
+ ),
+ )
+ } else {
+ (
+ slideInHorizontally(
+ tween(duration),
+ ) { -it } +
+ fadeIn(
+ androidx.compose.animation.core
+ .tween(duration),
+ )
+ ).togetherWith(
+ slideOutHorizontally(
+ tween(duration),
+ ) { it } +
+ fadeOut(
+ androidx.compose.animation.core
+ .tween(duration),
+ ),
+ )
+ }
+ },
+ label = "ColumnNavAnimation",
+ ) { overlayScreen ->
+ if (overlayScreen != null) {
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ OverlayContent(
+ screen = overlayScreen,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ highlightStore = highlightStore,
+ draftStore = draftStore,
+ onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
+ onZapFeedback = onZapFeedback,
+ onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
+ onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
+ onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
+ onBack = { navState.pop() },
+ )
+ }
}
}
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt
index 96058bcf88..aba4cbd9c5 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
@@ -119,27 +120,31 @@ fun DeckLayout(
)
}
- DeckColumnContainer(
- column = column,
- canClose = columns.size > 1,
- onClose = { deckState.removeColumn(column.id) },
- onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) },
- relayManager = relayManager,
- localCache = localCache,
- accountManager = accountManager,
- account = account,
- iAccount = iAccount,
- nwcConnection = nwcConnection,
- subscriptionsCoordinator = subscriptionsCoordinator,
- highlightStore = highlightStore,
- draftStore = draftStore,
- nip11Fetcher = nip11Fetcher,
- appScope = appScope,
- onShowComposeDialog = onShowComposeDialog,
- onShowReplyDialog = onShowReplyDialog,
- onZapFeedback = onZapFeedback,
- onNavigateToRelays = onNavigateToRelays,
- )
+ // Key by column id so reorder/remove doesn't re-run the
+ // child's `LaunchedEffect(Unit)` (which grabs keyboard focus).
+ key(column.id) {
+ DeckColumnContainer(
+ column = column,
+ canClose = columns.size > 1,
+ onClose = { deckState.removeColumn(column.id) },
+ onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) },
+ relayManager = relayManager,
+ localCache = localCache,
+ accountManager = accountManager,
+ account = account,
+ iAccount = iAccount,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ highlightStore = highlightStore,
+ draftStore = draftStore,
+ nip11Fetcher = nip11Fetcher,
+ appScope = appScope,
+ onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
+ onZapFeedback = onZapFeedback,
+ onNavigateToRelays = onNavigateToRelays,
+ )
+ }
}
}
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt
index 34554d5e18..623819af24 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt
@@ -113,6 +113,7 @@ fun MainSidebar(
onOpenSettings: () -> Unit,
onNavigate: (DeckColumnType) -> Unit,
activeColumnType: DeckColumnType?,
+ feedTabActive: Boolean = false,
onShowImportFollowListDialog: () -> Unit = {},
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
@@ -183,12 +184,14 @@ fun MainSidebar(
) {
NAV_ITEMS.forEach { item ->
val isActive = activeColumnType?.typeKey() == item.type.typeKey()
+ val isMuted = isActive && item.type is DeckColumnType.HomeFeed && feedTabActive
SidebarNavItem(
icon = item.icon,
label = item.label,
isActive = isActive,
expanded = expanded,
onClick = { onNavigate(item.type) },
+ muted = isMuted,
)
}
@@ -439,28 +442,30 @@ private fun SidebarNavItem(
isActive: Boolean,
expanded: Boolean,
onClick: () -> Unit,
+ muted: Boolean = false,
) {
var isHovered by remember { mutableStateOf(false) }
val backgroundColor =
when {
+ isActive && muted -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
isActive -> MaterialTheme.colorScheme.primaryContainer
isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
else -> MaterialTheme.colorScheme.surface
}
val iconTint =
- if (isActive) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
+ when {
+ isActive && muted -> MaterialTheme.colorScheme.onSurfaceVariant
+ isActive -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val textColor =
- if (isActive) {
- MaterialTheme.colorScheme.onPrimaryContainer
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
+ when {
+ isActive && muted -> MaterialTheme.colorScheme.onSurfaceVariant
+ isActive -> MaterialTheme.colorScheme.onPrimaryContainer
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
@@ -491,7 +496,7 @@ private fun SidebarNavItem(
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
- fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
+ fontWeight = if (isActive && !muted) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt
index 922f69f934..29e7779a3b 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt
@@ -20,6 +20,13 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -74,8 +81,7 @@ fun SinglePaneLayout(
) {
val currentColumnType by singlePaneState.currentScreen.collectAsState()
val navState = remember { ColumnNavigationState() }
- val navStack by navState.stack.collectAsState()
- val currentOverlay = navStack.lastOrNull()
+ val currentOverlay = navState.current
// Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes).
// SinglePaneLayout only renders the content pane.
@@ -124,28 +130,43 @@ fun SinglePaneLayout(
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
onOpenFeedsDrawer = onOpenFeedsDrawer,
)
- if (currentOverlay != null) {
- Surface(
- color = MaterialTheme.colorScheme.background,
- modifier = Modifier.fillMaxSize(),
- ) {
- OverlayContent(
- screen = currentOverlay,
- relayManager = relayManager,
- localCache = localCache,
- account = account,
- nwcConnection = nwcConnection,
- subscriptionsCoordinator = subscriptionsCoordinator,
- highlightStore = highlightStore,
- draftStore = draftStore,
- onShowComposeDialog = onShowComposeDialog,
- onShowReplyDialog = onShowReplyDialog,
- onZapFeedback = onZapFeedback,
- onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
- onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
- onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
- onBack = { navState.pop() },
- )
+ AnimatedContent(
+ targetState = currentOverlay,
+ transitionSpec = {
+ val duration = 200
+ if (navState.navigatingForward) {
+ (slideInHorizontally(tween(duration)) { it } + fadeIn(tween(duration)))
+ .togetherWith(slideOutHorizontally(tween(duration)) { -it } + fadeOut(tween(duration)))
+ } else {
+ (slideInHorizontally(tween(duration)) { -it } + fadeIn(tween(duration)))
+ .togetherWith(slideOutHorizontally(tween(duration)) { it } + fadeOut(tween(duration)))
+ }
+ },
+ label = "SinglePaneNavAnimation",
+ ) { overlayScreen ->
+ if (overlayScreen != null) {
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ OverlayContent(
+ screen = overlayScreen,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ highlightStore = highlightStore,
+ draftStore = draftStore,
+ onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
+ onZapFeedback = onZapFeedback,
+ onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
+ onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
+ onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
+ onBack = { navState.pop() },
+ )
+ }
}
}
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
index 3529fbed08..12eac87cf2 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
@@ -101,6 +101,8 @@ fun NoteCard(
onImageClick: ((List, Int) -> Unit)? = null,
onMediaClick: ((List, Int, Float) -> Unit)? = null,
onPayInvoice: ((String) -> Unit)? = null,
+ bottomContent: (@Composable ColumnScope.() -> Unit)? = null,
+ headerTrailingContent: (@Composable () -> Unit)? = null,
) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls =
@@ -161,21 +163,25 @@ fun NoteCard(
Column {
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Author with avatar — stadium-shaped hover to match the
// avatar+name chip's visual shape.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
- if (onAuthorClick != null) {
- Modifier
- .clip(RoundedCornerShape(100.dp))
- .clickable { onAuthorClick(note.pubKeyHex) }
- } else {
- Modifier
- },
+ Modifier
+ .weight(1f, fill = false)
+ .then(
+ if (onAuthorClick != null) {
+ Modifier
+ .clip(RoundedCornerShape(100.dp))
+ .clickable { onAuthorClick(note.pubKeyHex) }
+ } else {
+ Modifier
+ },
+ ),
) {
UserAvatar(
userHex = note.pubKeyHex,
@@ -194,6 +200,10 @@ fun NoteCard(
)
}
+ if (headerTrailingContent != null) {
+ headerTrailingContent()
+ }
+
// Timestamp
ToggleableTimeAgoText(
timestamp = note.createdAt,
@@ -306,6 +316,10 @@ fun NoteCard(
}
}
}
+
+ if (bottomContent != null) {
+ bottomContent()
+ }
}
if (onClick != null) {
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt
new file mode 100644
index 0000000000..7287250cba
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.desktop.ui.note
+
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
+import java.awt.Toolkit
+import java.awt.datatransfer.StringSelection
+
+class ShareMenuState {
+ var expanded by mutableStateOf(false)
+ private set
+
+ fun open() {
+ expanded = true
+ }
+
+ fun dismiss() {
+ expanded = false
+ }
+}
+
+@Composable
+fun rememberShareMenuState(): ShareMenuState = remember { ShareMenuState() }
+
+@Composable
+fun ShareMenu(
+ state: ShareMenuState,
+ event: Event,
+ relayManager: DesktopRelayConnectionManager,
+) {
+ DropdownMenu(
+ expanded = state.expanded,
+ onDismissRequest = { state.dismiss() },
+ ) {
+ DropdownMenuItem(
+ text = { Text("Copy Text") },
+ onClick = {
+ copyToClipboard(event.content)
+ state.dismiss()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text("Copy Note ID") },
+ onClick = {
+ copyToClipboard("nostr:${NNote.create(event.id)}")
+ state.dismiss()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text("Copy Event Link") },
+ onClick = {
+ val relays = relayManager.connectedRelays.value.take(3)
+ copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}")
+ state.dismiss()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text("Copy Raw JSON") },
+ onClick = {
+ copyToClipboard(event.toJson())
+ state.dismiss()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text("Copy Web Link") },
+ onClick = {
+ val nevent = NEvent.create(event.id, event.pubKey, event.kind, emptyList())
+ copyToClipboard("https://njump.me/$nevent")
+ state.dismiss()
+ },
+ )
+ HorizontalDivider()
+ DropdownMenuItem(
+ text = { Text("Broadcast") },
+ onClick = {
+ relayManager.broadcastToAll(event)
+ state.dismiss()
+ },
+ )
+ }
+}
+
+private fun copyToClipboard(text: String) {
+ val clipboard = Toolkit.getDefaultToolkit().systemClipboard
+ clipboard.setContents(StringSelection(text), null)
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt
new file mode 100644
index 0000000000..7097592a4a
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt
@@ -0,0 +1,165 @@
+/*
+ * 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.desktop.ui.thread
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
+import com.vitorpamplona.amethyst.commons.util.toZapAmount
+
+@Composable
+fun CommentItem(
+ authorName: String,
+ authorHandle: String,
+ authorAvatarUrl: String?,
+ authorPubKeyHex: String,
+ content: String,
+ timeAgo: String,
+ reactionCount: Int,
+ zapAmount: Long,
+ isLiked: Boolean = false,
+ isZapped: Boolean = false,
+ onReply: () -> Unit = {},
+ onLike: () -> Unit = {},
+ onZap: () -> Unit = {},
+ onAuthorClick: () -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier = modifier) {
+ UserAvatar(
+ userHex = authorPubKeyHex,
+ pictureUrl = authorAvatarUrl,
+ size = 36.dp,
+ modifier = Modifier.clickable(onClick = onAuthorClick),
+ )
+ Spacer(Modifier.width(8.dp))
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = authorName,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
+ modifier = Modifier.clickable(onClick = onAuthorClick),
+ )
+ Text(
+ text = " @$authorHandle · $timeAgo",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = onReply) {
+ Icon(
+ symbol = MaterialSymbols.Chat,
+ contentDescription = "Reply",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "Reply",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.width(16.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clickable(onClick = onLike),
+ ) {
+ val likeColor =
+ if (isLiked) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ val likeSymbol =
+ if (isLiked) {
+ MaterialSymbols.Favorite
+ } else {
+ MaterialSymbols.FavoriteBorder
+ }
+ Icon(
+ symbol = likeSymbol,
+ contentDescription = "Like",
+ modifier = Modifier.size(16.dp),
+ tint = likeColor,
+ )
+ if (reactionCount > 0) {
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = reactionCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = likeColor,
+ )
+ }
+ }
+ Spacer(Modifier.width(16.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clickable(onClick = onZap),
+ ) {
+ val zapColor =
+ if (isZapped) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ Icon(
+ symbol = MaterialSymbols.Bolt,
+ contentDescription = "Zap",
+ modifier = Modifier.size(16.dp),
+ tint = zapColor,
+ )
+ if (zapAmount > 0) {
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = zapAmount.toZapAmount(),
+ style = MaterialTheme.typography.labelSmall,
+ color = zapColor,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt
new file mode 100644
index 0000000000..417887335b
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.desktop.ui.thread
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CommentsCard(
+ commentCount: Int,
+ replyContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val outlineVariant = MaterialTheme.colorScheme.outlineVariant
+ val cardBorder = remember(outlineVariant) { BorderStroke(1.dp, outlineVariant) }
+ val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface)
+
+ OutlinedCard(
+ modifier = modifier.fillMaxWidth(),
+ border = cardBorder,
+ colors = cardColors,
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ // Header
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "Comments",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.width(8.dp))
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Text(
+ text = commentCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
+ )
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = "Most recent",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ // Reply input slot
+ replyContent()
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Comment items
+ content()
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt
new file mode 100644
index 0000000000..17cf1dc3f3
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt
@@ -0,0 +1,186 @@
+/*
+ * 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.desktop.ui.thread
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.isMetaPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
+import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
+import kotlinx.coroutines.launch
+
+sealed interface SendState {
+ data object Idle : SendState
+
+ data object Sending : SendState
+
+ data class Error(
+ val message: String,
+ ) : SendState
+}
+
+@Composable
+fun InlineReplyInput(
+ myAvatarUrl: String?,
+ onSend: suspend (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var text by remember { mutableStateOf("") }
+ var sendState by remember { mutableStateOf(SendState.Idle) }
+ val scope = rememberCoroutineScope()
+
+ val isSending = sendState is SendState.Sending
+
+ fun doSend() {
+ val content = text.trim()
+ if (content.isEmpty() || isSending) return
+ sendState = SendState.Sending
+ scope.launch {
+ try {
+ onSend(content)
+ text = ""
+ sendState = SendState.Idle
+ } catch (e: Exception) {
+ sendState = SendState.Error(e.message ?: "Failed to send reply")
+ }
+ }
+ }
+
+ Column(modifier = modifier) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ UserAvatar(
+ userHex = "",
+ pictureUrl = myAvatarUrl,
+ size = 32.dp,
+ )
+ Spacer(Modifier.width(8.dp))
+ OutlinedTextField(
+ value = text,
+ onValueChange = {
+ text = it
+ // Clear error on new input
+ if (sendState is SendState.Error) sendState = SendState.Idle
+ },
+ placeholder = {
+ Text(
+ "Add a comment...",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ modifier =
+ Modifier
+ .weight(1f)
+ .onPreviewKeyEvent { keyEvent ->
+ if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.Enter) {
+ val modifierHeld =
+ if (PlatformInfo.isMacOS) {
+ keyEvent.isMetaPressed
+ } else {
+ keyEvent.isCtrlPressed
+ }
+ if (modifierHeld) {
+ doSend()
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ },
+ textStyle = MaterialTheme.typography.bodyMedium,
+ singleLine = false,
+ maxLines = 5,
+ )
+ Spacer(Modifier.width(8.dp))
+ Button(
+ onClick = { doSend() },
+ enabled = text.isNotBlank() && !isSending,
+ shape = RoundedCornerShape(20.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ ),
+ modifier = Modifier.height(36.dp),
+ ) {
+ if (isSending) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Icon(
+ MaterialSymbols.AutoMirrored.Send,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Send", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ }
+
+ // Error message
+ val error = sendState
+ if (error is SendState.Error) {
+ Text(
+ text = error.message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(start = 40.dp, top = 4.dp),
+ )
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt
new file mode 100644
index 0000000000..a9252ccd93
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt
@@ -0,0 +1,330 @@
+/*
+ * 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.desktop.ui.thread
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.vitorpamplona.amethyst.commons.feeds.related.CompactNoteData
+import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
+import com.vitorpamplona.amethyst.commons.richtext.UrlParser
+import com.vitorpamplona.amethyst.commons.util.showAmount
+import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
+import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import java.math.BigDecimal
+
+/**
+ * Horizontal scrollable row of compact related content cards.
+ * Shows related posts by hashtag and author for the given note.
+ * Only loads when the thread view is open.
+ */
+@Composable
+fun RelatedContentSection(
+ noteId: String,
+ authorPubKey: String,
+ noteHashtags: Set,
+ localCache: DesktopLocalCache,
+ onItemClick: (String) -> Unit,
+ onViewAll: () -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet()
+
+ // Re-scan the cache initially and whenever a bundle of new events arrives
+ // that contains a candidate (same hashtag or same author). Without this,
+ // expanding a note on a cold cache leaves the section empty until the user
+ // collapses + re-expands.
+ val relatedItems by produceState>(
+ initialValue = emptyList(),
+ key1 = noteId,
+ key2 = authorPubKey,
+ key3 = lowercaseTags,
+ ) {
+ fun rescan() {
+ runCatching {
+ value = scanRelated(localCache, noteId, authorPubKey, lowercaseTags)
+ }
+ }
+ rescan()
+ localCache.eventStream.newEventBundles.collect { bundle ->
+ val matters =
+ bundle.any { n ->
+ val ev = n.event
+ ev is TextNoteEvent &&
+ n.idHex != noteId &&
+ (
+ ev.pubKey == authorPubKey ||
+ (lowercaseTags.isNotEmpty() && ev.tags.isTaggedHashes(lowercaseTags))
+ )
+ }
+ if (matters) rescan()
+ }
+ }
+
+ if (relatedItems.isNotEmpty()) {
+ val primaryHashtag = noteHashtags.firstOrNull()
+ Column(modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (primaryHashtag != null) {
+ Row {
+ Text(
+ text = "Related from ",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Text(
+ text = "#$primaryHashtag",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ } else {
+ Text(
+ text = "More from this author",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ }
+ Text(
+ text = "View all >",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.clickable(onClick = onViewAll),
+ )
+ }
+
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp),
+ ) {
+ items(relatedItems, key = { it.id }) { item ->
+ CompactRelatedCard(
+ item = item,
+ onClick = { onItemClick(item.id) },
+ )
+ }
+ }
+ }
+ }
+}
+
+private const val RELATED_LIMIT = 6
+
+/**
+ * Scan the local cache for notes related to [noteId] either by sharing a
+ * hashtag in [lowercaseTags] or by being authored by [authorPubKey]. Returns
+ * up to [RELATED_LIMIT] notes, most recent first, mapped to [CompactNoteData].
+ *
+ * Runs O(N) over `localCache.notes` — backed by `ConcurrentSkipListMap` which
+ * supports concurrent inserts during iteration (weakly consistent). Safe on
+ * the main composition coroutine for typical cache sizes (~30k notes).
+ */
+private fun scanRelated(
+ localCache: DesktopLocalCache,
+ noteId: String,
+ authorPubKey: String,
+ lowercaseTags: Set,
+): List {
+ val results = mutableListOf()
+
+ if (lowercaseTags.isNotEmpty()) {
+ localCache.notes.forEach { _, note ->
+ if (note.idHex != noteId &&
+ note.event is TextNoteEvent &&
+ note.event?.tags?.isTaggedHashes(lowercaseTags) == true
+ ) {
+ results.add(note)
+ }
+ }
+ }
+
+ if (results.size < RELATED_LIMIT) {
+ localCache.notes.forEach { _, note ->
+ if (note.idHex != noteId &&
+ note.event is TextNoteEvent &&
+ note.event?.pubKey == authorPubKey &&
+ note !in results
+ ) {
+ results.add(note)
+ }
+ }
+ }
+
+ return results
+ .sortedByDescending { it.createdAt() }
+ .take(RELATED_LIMIT)
+ .map { note ->
+ val event = note.event
+ val content = event?.content ?: ""
+ val firstLine =
+ content
+ .take(80)
+ .lineSequence()
+ .firstOrNull()
+ ?.take(60) ?: ""
+ val author = localCache.getUserIfExists(event?.pubKey ?: "")
+ val imageUrl =
+ UrlParser()
+ .parseValidUrls(content)
+ .withScheme
+ .firstOrNull { RichTextParser.isImageUrl(it) }
+ CompactNoteData(
+ id = note.idHex,
+ title = firstLine.ifBlank { "Note" },
+ authorName = author?.toBestDisplayName() ?: event?.pubKey?.take(8) ?: "",
+ thumbnailUrl = imageUrl,
+ zapCount = if (note.zapsAmount > BigDecimal.ZERO) showAmount(note.zapsAmount) else "",
+ )
+ }
+}
+
+@Composable
+private fun CompactRelatedCard(
+ item: CompactNoteData,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val shape = MaterialTheme.shapes.medium
+ Card(
+ modifier =
+ modifier
+ .width(200.dp)
+ .height(140.dp)
+ .clickable(onClick = onClick),
+ shape = shape,
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Background: image or gradient placeholder
+ if (item.thumbnailUrl != null) {
+ AsyncImage(
+ model = item.thumbnailUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize().clip(shape),
+ )
+ } else {
+ Box(
+ modifier =
+ Modifier.fillMaxSize().background(
+ Brush.verticalGradient(
+ colors =
+ listOf(
+ MaterialTheme.colorScheme.surfaceVariant,
+ MaterialTheme.colorScheme.surface,
+ ),
+ ),
+ ),
+ )
+ }
+
+ // Dark gradient overlay at bottom
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(72.dp)
+ .align(Alignment.BottomCenter)
+ .background(
+ Brush.verticalGradient(
+ colors =
+ listOf(
+ Color.Transparent,
+ Color.Black.copy(alpha = 0.6f),
+ ),
+ ),
+ ),
+ )
+
+ // Text over the gradient
+ Column(
+ modifier =
+ Modifier
+ .align(Alignment.BottomStart)
+ .padding(10.dp),
+ ) {
+ Text(
+ text = item.title,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ color = Color.White,
+ )
+ Spacer(Modifier.height(2.dp))
+ val subtitle =
+ buildString {
+ append(item.authorName)
+ if (item.zapCount.isNotBlank()) {
+ append(" · ")
+ append(item.zapCount)
+ append(" zaps")
+ }
+ }
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White.copy(alpha = 0.8f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
diff --git a/docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md b/docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md
new file mode 100644
index 0000000000..4beeba620f
--- /dev/null
+++ b/docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md
@@ -0,0 +1,588 @@
+---
+title: Fix 5 review findings on PR #3124 desktop feed UI refresh
+type: fix
+status: active
+date: 2026-06-02
+pr: https://github.com/vitorpamplona/amethyst/pull/3124
+review_comment: https://github.com/vitorpamplona/amethyst/pull/3124#issuecomment-4599816576
+worktree: ../AmethystMultiplatform-feed-review
+branch: fix/desktop-feed-ui-review (tracks origin/feat/desktop-feed-ui-refresh)
+deepened: 2026-06-02
+---
+
+# Fix 5 review findings on PR #3124
+
+## Enhancement summary (2026-06-02 deepen-plan)
+
+Eight parallel agents resolved all 5 open questions and surfaced 3 plan revisions:
+
+**Open questions resolved:**
+- **Q1 — kind-1 NIP-10 vs kind-1111 NIP-22:** kind-1 NIP-10 confirmed. Android's
+ `NotificationReplyReceiver` already routes by parent type
+ (`TextNoteEvent` → kind 1, others → kind 1111). Desktop feed loads kind 1
+ only (`DesktopFeedFilters.kt:39`). Use kind 1 + `prepareETagsAsReplyTo`.
+- **Q2 — extract consume+broadcast couplet:** YES. Five clean call sites (no
+ inline complexity) + ordering inconsistency (`TextNoteEvent` does
+ consume→broadcast at `ThreadScreen.kt:361` and `FeedScreen.kt:1564`,
+ Reaction/Follow do broadcast→consume at `ThreadScreen.kt:424`,
+ `FeedScreen.kt:426`/`:1617`). A `desktopApp` extension fixes both volume and
+ the ordering drift. Canonical order: consume→broadcast (local-first).
+- **Q3 — Phase 4 produceState vs ViewModel:** produceState. `LargeCache.notes`
+ is a `ConcurrentSkipListMap` (`LargeCache.jvmAndroid.kt:27`) — weakly
+ consistent iterator, safe on main composition coroutine, 50–150ms for ~30k
+ notes. No debounce needed; candidate-filter pre-check blocks 80–90% of
+ bundles. `FeedViewModel.kt:54-59` precedent collects same stream without
+ debounce.
+- **Q4 — NoteActions.kt:264 formatSats:** sats, safe to swap to
+ `amount.toZapAmount()`. Inputs are hardcoded preset amounts (line 111
+ `ZAP_AMOUNTS = listOf(21L, 100L, ...)`) and `LnZapEvent.amount` which is
+ already sats (`LnZapEvent.kt:69`).
+- **Q5 — WalletColumnScreen.kt:979 formatSats:** intentional. Wallet shows
+ precise balance with locale-aware grouping (`1,000,000`). Leave + add
+ `// intentional` comment to prevent future drift.
+
+**Plan revisions:**
+- **Phase 1 path flattening:** move `ReplyActions` from
+ `commons/.../actions/nip10Notes/ReplyActions.kt` to flat
+ `commons/.../actions/ReplyActions.kt`. Sister actions (`FollowActions`,
+ `ZapActions`, `DmActions`, `SearchActions`) are all flat under `actions/`;
+ no `nipNN/` subpackage convention. (Architecture review)
+- **Phase 5 simplification:** drop the explicit `requestFocus()` in the Esc
+ handler; the column never loses focus during pop (Esc was *received by* the
+ focused column). Just `LaunchedEffect(Unit) { requestFocus() }` + the
+ existing `.focusable()`. Add `key(column.id) { DeckColumnContainer(...) }`
+ wrap in `DeckLayout.kt:111` so `LaunchedEffect(Unit)` survives column
+ reordering. (Code-simplicity + focus-audit review)
+- **Phase 2 promoted from "optional":** with 5 verified duplicates + order
+ inconsistency, extract `Account.dispatch(signed: Event)` as a
+ `desktopApp` extension. Canonical order: consume→broadcast. Not in
+ `commons` (relay manager + cache are desktop types).
+
+**Android follow-up (out of this PR):** 4 inlined `TextNoteEvent.build` sites
+on Android (`ShortNotePostViewModel.kt:1037`, `VoiceReplyViewModel.kt:265`,
+`NotificationReplyReceiver.kt:203`, `AmethystAppFunctions.kt:1051`) should
+migrate to the new `ReplyActions.replyTo` in a follow-up PR. Tracked in
+"Future work" below.
+
+## Overview
+
+Davotoula's review on PR #3124 (`feat/desktop-feed-ui-refresh`) flagged 5 issues
+ranging from one **NIP-10 protocol bug** (inline reply emits a tag set other
+clients can't thread) down to **consistency bugs** (zap totals bypass the shared
+formatter). All confirmed by inspecting `origin/feat/desktop-feed-ui-refresh`.
+
+This plan groups the fixes so dependent ones land in a sequence that compiles at
+each step, and routes the protocol/architectural fixes through existing shared
+helpers (`TextNoteEvent.build(replyingTo=…)`, `ReactionAction`, `FollowAction`,
+`ZapFormatter.showAmount`) rather than introducing new abstractions.
+
+## Findings (root-cause confirmed)
+
+### #3 — Inline reply emits lower-fidelity NIP-10 tag set [PROTOCOL BUG]
+
+**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt:1554`
+
+**Current code:**
+```kotlin
+val template = TextNoteEvent.build(content) {
+ val etag = ETag(event.id)
+ etag.relay = null
+ etag.author = event.pubKey
+ eTag(etag)
+ pTag(PTag(event.pubKey, relayHint = null))
+}
+```
+
+**Root cause:** the call uses the **single-arg** `TextNoteEvent.build(note, initializer)`
+overload at `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt:133`
+and hand-rolls a minimal reply tag set. It emits a single unmarked `e`-tag and a
+single `p`-tag. **No NIP-10 root marker. No carry of the parent's root-e-tag.
+No carry of the parent's p-tag chain.** Replying to a note deep in a thread
+produces an event with no `root` reference; conformant clients (Damus, Primal,
+Coracle…) can't reconstruct the thread.
+
+**Fix:** switch to the **reply-aware overload** at `TextNoteEvent.kt:142`:
+
+```kotlin
+fun build(
+ note: String,
+ replyingTo: EventHintBundle? = null,
+ forkingFrom: EventHintBundle? = null,
+ …
+) = eventTemplate(KIND, note, createdAt) {
+ alt(shortedMessageForAlt(note))
+ if (replyingTo != null || forkingFrom != null) {
+ markedETags(prepareETagsAsReplyTo(replyingTo, forkingFrom))
+ }
+ initializer()
+}
+```
+
+`prepareETagsAsReplyTo` (already in quartz) handles **root marker, reply marker,
+parent root/p-tag carry** correctly. The inline path was bypassing it.
+
+### #4 — Reaction/follow/reply business logic in desktop composables
+
+**Files:**
+- `FeedScreen.kt:1611` — reaction (likes from inline expansion)
+- `FeedScreen.kt:423` — follow (follow pill from feed)
+- `FeedScreen.kt:1554` — reply (inline reply input)
+
+**Current state (verified):**
+- **Follow** already uses `FollowAction.follow(pubKeyHex, signer, currentList)`
+ (commons). The complaint is the surrounding `cache.consume → broadcast`
+ couplet inlined in the composable.
+- **Reaction** already uses `ReactionAction.reactTo(EventHintBundle, "+", signer)`
+ (commons). Same couplet inlined.
+- **Reply** does NOT use a shared builder (see #3).
+
+**CLAUDE.md rule (`commons/ARCHITECTURE.md:73-88`):** "actions package (CLI-safe):
+Event builders for user actions (follow, zap…). The canonical entry point for
+non-UI callers."
+
+**Fix:** introduce a new shared action that mirrors `FollowActions` for kind-1
+replies. The consume+broadcast couplet stays inline (2 lines, platform-specific
+relay/cache wiring), but the *protocol-touching* build moves out:
+
+```kotlin
+// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/nip10Notes/ReplyActions.kt
+object ReplyActions {
+ suspend fun replyTo(
+ parent: EventHintBundle,
+ content: String,
+ signer: NostrSigner,
+ ): TextNoteEvent {
+ val template = TextNoteEvent.build(content, replyingTo = parent)
+ return signer.sign(template)
+ }
+}
+```
+
+Desktop call site becomes:
+```kotlin
+val parentText = event as? TextNoteEvent ?: return@withContext
+val signed = ReplyActions.replyTo(EventHintBundle(parentText, null), content, account.signer)
+localCache.consume(signed, relay = null)
+relayManager.broadcastToAll(signed)
+```
+
+This matches the shape already used for `FollowAction.follow` at `FeedScreen.kt:423`
+and `ReactionAction.reactTo` at `NoteActions.kt:1393`. Drift between desktop and
+Android paths is bounded to a 3-line couplet that won't grow.
+
+### #1 — Related content stale after one scan
+
+**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt:83`
+
+**Current code:**
+```kotlin
+DisposableEffect(noteId) {
+ val results = mutableListOf()
+ // scan localCache.notes once
+ localCache.notes.forEach { … }
+ relatedItems = results.sortedByDescending { it.createdAt() }.take(6).map { … }
+ onDispose { }
+}
+```
+
+**Root cause:** `DisposableEffect(noteId)` re-runs only on `noteId` change. The
+scan reads `localCache.notes` (a `LargeCache`) at composition time; nothing
+re-runs the scan as the cache fills. Expanding a note on a cold cache leaves
+the section empty/partial until collapse+re-expand. Also missing keys:
+`noteHashtags` and `authorPubKey` (cosmetic — caller stabilises these per noteId).
+
+**Fix:** observe the cache's change stream and re-scan on bundle arrivals.
+`DesktopLocalCache` exposes `eventStream: DesktopCacheEventStream` with
+`newEventBundles: SharedFlow>` (`DesktopLocalCache.kt:719-743`).
+
+Two options:
+
+**Option A (simpler, matches inline-section scale):** `produceState` keyed by
+`(noteId, hashtagsHash, authorPubKey)` that collects `newEventBundles` and
+re-runs the scan when relevant events land:
+
+```kotlin
+val relatedItems by produceState>(emptyList(), noteId, authorPubKey, noteHashtags) {
+ fun rescan() { value = scanRelated(localCache, noteId, authorPubKey, noteHashtags) }
+ rescan() // initial
+ localCache.eventStream.newEventBundles.collect { bundle ->
+ if (bundle.any { isCandidate(it, noteHashtags, authorPubKey) }) rescan()
+ }
+}
+```
+
+**Option B (matches FeedViewModel family):** new `RelatedContentViewModel` in
+`commons/src/commonMain/.../viewmodels/related/`, taking a `FeedFilter` style
+"by hashtag OR by author" predicate and exposing
+`StateFlow>`. Heavier but consistent with the rest of the
+feed system.
+
+**Recommendation:** **Option A** — the related-content row is a 6-item sidecar,
+not a feed. A ViewModel adds wiring without solving an actual problem here.
+Deepen-plan agent may overrule.
+
+### #2 — Deck columns steal focus from each other
+
+**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:150`
+
+**Current code:**
+```kotlin
+val focusRequester = remember { FocusRequester() }
+LaunchedEffect(currentOverlay) { focusRequester.requestFocus() }
+```
+
+**Root cause:** `LaunchedEffect(currentOverlay)` fires on the column's first
+composition **and on every overlay change**. In a multi-column deck, when
+column B opens an overlay, column B's effect grabs focus — yanking it out of
+column A's inline reply input mid-typing.
+
+**Fix:** decouple "request focus once on initial composition" from "Escape
+handler needs focus to be live." The Escape key path works as long as the
+column owns focus when the user presses Escape — which it does after the
+initial composition. Match the existing pattern at
+`EditProfileScreen.kt:380` (`LaunchedEffect(Unit) { focusRequester.requestFocus() }`)
+**and** scope the effect so only the column the user is interacting with
+re-grabs focus when a nested overlay closes (i.e. on `popOverlay()`).
+
+Concretely:
+1. Change `LaunchedEffect(currentOverlay)` → `LaunchedEffect(Unit)` for the
+ initial focus request.
+2. When the user presses Escape and `navState.pop()` succeeds, explicitly call
+ `focusRequester.requestFocus()` in the key handler (intent-driven, not
+ composition-driven).
+
+This contains focus stealing to the column the user actually interacted with.
+
+### #5 — Zap totals bypass shared formatter
+
+**Files:**
+- `RelatedContentRow.kt:137` — `zapCount = "${note.zapsAmount.toLong()}"` (raw, e.g. `"1500000"`)
+- `CommentItem.kt:155` — `text = formatZapAmount(zapAmount)` calling local helper
+- `CommentItem.kt:166-171` — private `formatZapAmount(sats: Long)` hand-rolled k/M
+
+**Shared formatter (`commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt`):**
+- `fun showAmount(amount: BigDecimal?): String` — G/M/k suffixes, `""` for null/<0.01
+- `fun showAmountWithZero(amount: BigDecimal?): String` — same, `"0"` instead of `""`
+- `fun Long.toZapAmount(): String`
+- `fun Int.toZapAmount(): String`
+
+`Note.zapsAmount` is `BigDecimal` (`commons/src/commonMain/.../model/Note.kt:183`),
+so use `showAmount(note.zapsAmount)` directly in `RelatedContentRow`. `CommentItem`
+takes a `Long`, so use `zapAmount.toZapAmount()` and delete the local helper.
+
+**Also flagged (outside review but same root cause):**
+- `NoteActions.kt:264` — local `formatSats(amount: Long)` with only `k` suffix.
+- `WalletColumnScreen.kt:979` — local `formatSats` using `NumberFormat` (intentional?
+ wallet flows may want full sats — leave but document).
+
+Cover the two review-flagged sites + `NoteActions.kt:264`. Defer wallet.
+
+## Phased implementation plan
+
+Phases ordered so each compiles + tests cleanly without depending on later work.
+
+### Phase 1 — Shared `ReplyActions` (fixes #3, completes #4 reply)
+
+**Create:** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt` *(flat — matches `FollowActions.kt`, `ZapActions.kt`, `DmActions.kt`; no `nip10Notes/` subpackage)*
+
+```kotlin
+object ReplyActions {
+ suspend fun replyTo(
+ parent: EventHintBundle,
+ content: String,
+ signer: NostrSigner,
+ ): TextNoteEvent {
+ val template = TextNoteEvent.build(content, replyingTo = parent)
+ return signer.sign(template)
+ }
+}
+```
+
+Mirror `FollowActions.buildFollow` shape (`commons/.../actions/FollowActions.kt:69`).
+
+**Test:** `commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt`
+— assert the signed event has:
+- A marked e-tag with `marker=reply` pointing at the parent id.
+- A marked e-tag with `marker=root` (pointing at parent's root if parent had one,
+ else parent's id).
+- All parent p-tags carried + parent's `pubKey` appended.
+
+Use `runTest { … }` from `kotlinx-coroutines-test`, in-test signer is
+`NostrSignerInternal(KeyPair(privHex.hexToByteArray()))` — match `FollowActionsTest.kt:25-37`.
+
+**Edit:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt` (~ line 1554) — replace inline build with `ReplyActions.replyTo(EventHintBundle(parentText, null), content, account.signer)`. Guard parent kind with `event as? TextNoteEvent`; if not a kind-1, skip / log (replies to non-kind-1 from the feed inline path were never well-defined and are out of scope; matches Android's `NotificationReplyReceiver.kt:136-156` routing).
+
+**Acceptance:**
+- [ ] `./gradlew :commons:jvmTest --tests "*ReplyActionsTest*"` green.
+- [ ] Manual: reply to a deep-thread note from desktop, inspect the broadcast event in a relay log → has `e-tag root` + `e-tag reply` + carries all parent `p` tags.
+- [ ] Reply renders in Damus/Primal under the correct thread.
+
+### Phase 2 — Extract reaction/follow/reply consume+broadcast couplet (Option B confirmed)
+
+Deepen-plan audit found **5 clean duplicate sites** with an ordering
+inconsistency between them:
+
+| File:line | Signer | Order today |
+|---|---|---|
+| `ThreadScreen.kt:361` | inline `TextNoteEvent.build` reply | consume → broadcast |
+| `ThreadScreen.kt:424` | `ReactionAction.reactTo` | broadcast → consume |
+| `FeedScreen.kt:426` | `FollowAction.follow` | broadcast → consume |
+| `FeedScreen.kt:1564` | inline `TextNoteEvent.build` reply | consume → broadcast |
+| `FeedScreen.kt:1617` | `ReactionAction.reactTo` | broadcast → consume |
+
+No site has inline extra work (snackbars, retries) entangled with the couplet.
+
+**Create:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt`
+
+```kotlin
+/**
+ * Canonical local-first dispatch: write to the local cache before broadcasting
+ * so the UI reflects the user's action immediately, even if relay round-trips fail.
+ */
+suspend fun dispatch(
+ signed: Event,
+ localCache: DesktopLocalCache,
+ relayManager: RelayManager,
+) {
+ localCache.consume(signed, relay = null)
+ relayManager.broadcastToAll(signed)
+}
+```
+
+(Or, equivalent — as an extension on a small `DispatchContext` if the call
+sites already have one. Keep in `desktopApp` because both `LocalCache` and
+`RelayManager` are desktop-side types.)
+
+**Migrate all 5 sites** to call `dispatch(signed, localCache, relayManager)`.
+Fixes the ordering drift (everyone goes local-first) and shrinks call sites
+to one line.
+
+**Acceptance:**
+- [ ] `grep -rn "broadcastToAll" desktopApp/` shows only the call inside `EventDispatch.kt` + any non-couplet uses.
+- [ ] No remaining call sites do consume + broadcast inline (other than the helper).
+- [ ] Reactions, follows, and replies all still round-trip correctly in a manual sanity test.
+
+### Phase 3 — ZapFormatter swap (fixes #5)
+
+**Edit:** `RelatedContentRow.kt:137` —
+```kotlin
+zapCount = if (note.zapsAmount > BigDecimal.ZERO) showAmount(note.zapsAmount) else "",
+```
+
+**Edit:** `CommentItem.kt:155, 166-171` — replace `formatZapAmount(zapAmount)` with
+`zapAmount.toZapAmount()`. **Delete the private `formatZapAmount` fun at line 166-171.**
+
+**Edit:** `NoteActions.kt:264` — swap to `amount.toZapAmount()`. Verified
+sats (not msats): inputs are `ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)`
+at line 111 + `LnZapEvent.amount` which is sats per `LnZapEvent.kt:69`.
+**Delete the private `formatSats` fun if no remaining references** (run
+`grep -n formatSats desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt` after swap).
+
+**Skip:** `WalletColumnScreen.kt:979` `formatSats` is **intentional** — wallet
+balance display uses locale-aware grouping (`NumberFormat.getNumberInstance().format`)
+for full-precision sats. Out of review scope. Add a one-line `// intentional: wallet shows precise sats with locale grouping; not a ZapFormatter target` comment to prevent future drift.
+
+**Acceptance:**
+- [ ] `./gradlew :desktopApp:compileKotlin` green.
+- [ ] Visual: a note with 1.5M sats renders `1.5M` (or `1M`, matching commons
+ semantics), not `1500000`.
+
+### Phase 4 — Cache-aware Related (fixes #1)
+
+**Edit:** `RelatedContentRow.kt:83-145` — replace `DisposableEffect(noteId)` with
+`produceState` (Option A) keyed on `(noteId, authorPubKey, noteHashtags)`:
+
+```kotlin
+val relatedItems by produceState>(
+ initialValue = emptyList(),
+ key1 = noteId, key2 = authorPubKey, key3 = noteHashtags,
+) {
+ val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet()
+ fun rescan() {
+ runCatching {
+ value = scanRelated(localCache, noteId, authorPubKey, lowercaseTags)
+ }.onFailure {
+ // weakly-consistent iterator may rarely surface; skip this tick
+ }
+ }
+ rescan()
+ localCache.eventStream.newEventBundles.collect { bundle ->
+ val matters = bundle.any { n ->
+ n.event is TextNoteEvent &&
+ n.idHex != noteId &&
+ (n.event?.pubKey == authorPubKey ||
+ n.event?.tags?.isTaggedHashes(lowercaseTags) == true)
+ }
+ if (matters) rescan()
+ }
+}
+```
+
+Extract the existing scan body into a private top-level `scanRelated(...)` so
+both the initial call and the bundle-driven re-run share it.
+
+**Safety notes (deepen-plan):**
+- `LargeCache.notes` is `ConcurrentSkipListMap` (`LargeCache.jvmAndroid.kt:27`)
+ — weakly-consistent iterator, safe on main composition coroutine.
+- Scan is O(N): ~50–150ms for ~30k notes; fine on main.
+- No debounce: candidate-filter blocks 80–90% of bundles. Matches
+ `FeedViewModel.kt:54-59` precedent (collects same stream without debounce).
+- If hot-loop observed in production, retroactively add `.debounce(150)`
+ (precedent: `SearchBarState.kt:87`, `BookmarkListState.kt`).
+
+**Acceptance:**
+- [ ] Cold-cache repro: open a note in a fresh session, related section starts
+ empty; as kind-1 events stream in matching the hashtag or author, related
+ cards appear without collapse+re-expand.
+- [ ] No re-render storm: rescan only fires when a bundle contains a candidate.
+
+### Phase 5 — Focus gating (fixes #2)
+
+**Edit:** `DeckColumnContainer.kt:147-152` —
+```kotlin
+LaunchedEffect(Unit) { focusRequester.requestFocus() } // once on column creation
+```
+
+That's the only effect change. **No explicit `requestFocus()` in the Escape
+handler**: deepen-plan focus-audit verified the column never loses focus during
+back-nav (Escape was received by the focused column → it still has focus after
+`navState.pop()`). Adding it would be cargo-cult.
+
+**Also edit:** `DeckLayout.kt:111` — wrap the `forEachIndexed` body in a
+`key(column.id) { DeckColumnContainer(...) }` so `LaunchedEffect(Unit)`
+survives column reordering. Without `key()`, moving a column in the deck list
+re-fires the effect for the wrong column instance.
+
+**Acceptance:**
+- [ ] Two-column repro: in column A's inline reply text field, type characters
+ while column B opens/closes an overlay → column A keeps focus, no
+ characters lost.
+- [ ] Escape still pops nested overlays in the focused column.
+- [ ] Reorder a column in the deck (drag if supported, or remove+re-add) →
+ typing focus in unrelated columns is preserved.
+
+## System-Wide Impact
+
+### Interaction graph
+
+- **Reply path:** `InlineReplyInput.onSend(content)` → `ReplyActions.replyTo(...)` (commons) → `signer.sign` → `localCache.consume` (DesktopLocalCache) → `relayManager.broadcastToAll` (NostrClient WS pool) → relay round-trip → cache update → `eventStream.newEventBundles` → Phase-4 `produceState` re-scan → related section refresh. Phase 4 is downstream of Phase 1 only by happy coincidence (a reply might match its parent's hashtags) — no hard coupling.
+- **Follow path:** unchanged, already routed through `FollowAction.follow`.
+- **Reaction path:** unchanged, already routed through `ReactionAction.reactTo`.
+
+### Error propagation
+
+- `ReplyActions.replyTo` is `suspend` and propagates `signer.sign` failures (cancellation, signer rejection). Desktop call site already runs in `withContext(Dispatchers.IO)` — wrap in `try/catch` to surface a snackbar on signing failure (Android path does this in `CommentPostViewModel.sendPostSync`; desktop currently swallows).
+- Phase 4 `produceState` collect runs in the column's coroutine scope; cancelled when composable leaves composition. Exceptions in `scanRelated` (e.g. ConcurrentModificationException on `LargeCache.forEach`) would crash the collector — wrap `rescan()` body in `runCatching` to skip on transient cache mutations.
+
+### State lifecycle risks
+
+- Phase 1: signed reply written to `localCache` before broadcast succeeds. If
+ the broadcast fails, the reply is visible locally but not on relays.
+ This matches existing behaviour for reaction/follow paths; no new risk.
+- Phase 4: `produceState` collects an unbounded `SharedFlow`. If `newEventBundles`
+ emits at high rate (cold cache fill), `scanRelated` runs O(N) per bundle.
+ `LargeCache.notes` size for an active user is ~10k–50k notes; a full scan is
+ ~ms. Acceptable; if hot-loop observed, debounce via `collectLatest` +
+ `delay(150)`.
+
+### API surface parity
+
+- `ReplyActions` is JVM-only consumer today (desktop), but lives in
+ `commons/commonMain` so Android can adopt it (and should — `NewPostViewModel`
+ on Android currently inlines a similar `TextNoteEvent.build` call). Tracked as
+ follow-up, **not in this PR**.
+
+### Integration test scenarios
+
+1. **NIP-10 thread fidelity:** create note A → reply B to A → reply C to B from
+ desktop. Inspect C's tags: must contain `["e", A.id, "", "root"]` and
+ `["e", B.id, "", "reply"]` and `["p", A.pubKey]` + `["p", B.pubKey]`.
+2. **Cold-cache related:** clear local DB, open a thread → Related row empty →
+ simulate incoming kind-1 events matching parent's hashtag → row populates
+ without user interaction.
+3. **Multi-column focus:** open two columns side by side. Start typing in column
+ A's inline reply. Open a profile overlay in column B. Verify typed characters
+ stay in column A.
+4. **Reply to non-kind-1:** open a thread whose root is a `LongFormContentEvent`
+ (kind 30023). Inline reply must either disable (preferred) or route through
+ `CommentEvent` (NIP-22) — open question below.
+5. **Zap formatting:** seed a note with 1_500_000 sats zaps. Both `RelatedContentRow`
+ and `CommentItem` render `1.5M` (or `1M` per commons rules).
+
+## Acceptance criteria (rollup)
+
+### Functional
+
+- [ ] Inline-reply event from desktop, when broadcast, threads correctly in
+ ≥1 non-Amethyst client (Damus or Primal verified).
+- [ ] Related section refreshes from cold cache without user interaction.
+- [ ] Typing in column A's reply box doesn't lose focus when column B opens an
+ overlay.
+- [ ] Zap totals render with k/M/G suffix in `RelatedContentRow` and
+ `CommentItem`.
+- [ ] Existing inline reaction/follow continue to work (no regression).
+
+### Non-functional
+
+- [ ] No new `--no-verify` commits.
+- [ ] `./gradlew spotlessApply` clean.
+- [ ] `./gradlew test` green for `:commons:jvmTest` and `:quartz:jvmTest`.
+- [ ] No new Kotlin warnings introduced.
+
+### Quality gates
+
+- [ ] `ReplyActionsTest` covers root-marker, reply-marker, p-tag carry.
+- [ ] Hand-rolled `formatZapAmount` deleted (grep returns 0 in `desktopApp/`).
+
+## Dependencies & risks
+
+| Risk | Likelihood | Mitigation |
+|---|---|---|
+| `EventHintBundle` cast fails when parent is `CommentEvent` / `LongFormContentEvent` | Med | Guard with `as? TextNoteEvent`; skip + log if null. Open question covers full support. |
+| `produceState` re-runs scan storm on cold cache fill | Low | Filter bundle for candidate match before rescan; debounce if observed. |
+| `LargeCache.forEach` concurrent modification during rescan | Low | Wrap rescan body in `runCatching`. |
+| Focus fix breaks ESC → back-nav inside a column | Low | Explicit `requestFocus()` in pop handler covers it; manual repro before push. |
+| `ZapFormatter.showAmount` returns `""` for amount < 0.01 — different from current `"0"`-on-empty | Low | Use `showAmountWithZero` if `"0"` desired, else gate with `if (note.zapsAmount > ZERO)`. |
+
+## Resolved questions (deepen-plan)
+
+All Q1–Q5 resolved — see "Enhancement summary" at top for verdicts + evidence.
+
+## Future work (separate PR, not in this branch)
+
+- **Android kind-1 reply migration.** Four Android sites inline
+ `TextNoteEvent.build` and should migrate to the new `ReplyActions.replyTo`
+ (single source of truth across platforms):
+ - `amethyst/.../ShortNotePostViewModel.kt:1037`
+ - `amethyst/.../VoiceReplyViewModel.kt:265`
+ - `amethyst/.../NotificationReplyReceiver.kt:203`
+ - `amethyst/.../AmethystAppFunctions.kt:1051`
+- **CLI `amy reply` verb.** `ReplyActions` lives in `commons/commonMain` and
+ is CLI-safe — a future Amy reply verb wires straight to it.
+- **Wallet vs Zap formatter consolidation.** `WalletColumnScreen.kt:979`
+ intentionally diverges (locale-aware full-precision). Revisit if/when a
+ unified "amount display" component is built.
+
+## Sources & references
+
+### Internal references
+
+- Review comment: https://github.com/vitorpamplona/amethyst/pull/3124#issuecomment-4599816576
+- PR: https://github.com/vitorpamplona/amethyst/pull/3124
+- `commons/ARCHITECTURE.md:73-88` — actions package boundary
+- `quartz/.../nip10Notes/TextNoteEvent.kt:142` — reply-aware build overload
+- `quartz/.../nip10Notes/tags/MarkedETag.kt:44-60` — NIP-10 marker enum + tag-array
+- `quartz/.../nip10Notes/tags/prepareETagsAsReplyTo.kt` — root/reply tag-carry helper
+- `commons/.../actions/FollowActions.kt:69` — pattern to mirror for `ReplyActions`
+- `commons/.../model/nip25Reactions/ReactionAction.kt:50` — sister action
+- `commons/.../util/ZapFormatter.kt` — shared zap-amount formatter
+- `desktopApp/.../cache/DesktopLocalCache.kt:719-743` — `eventStream.newEventBundles`
+- `desktopApp/.../ui/EditProfileScreen.kt:380` — `LaunchedEffect(Unit)` focus pattern to mirror
+- `amethyst/.../ui/note/nip22Comments/CommentPostViewModel.kt:128, 447-571` — Android reply path (NIP-22 reference, not directly reused)
+
+### CLAUDE.md conventions
+
+- "Check existing implementations first — most logic already exists" — confirmed: shared helpers exist; this is reuse, not new abstraction.
+- "Pre-commit hooks run spotless — always `./gradlew spotlessApply` before commit"
+- "Never use `--no-verify`"
+- "Verify, Don't Guess" — root causes verified by reading code at each line cited.