Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
This commit is contained in:
Claude
2026-06-02 14:59:28 +00:00
41 changed files with 3130 additions and 297 deletions
+28
View File
@@ -218,6 +218,34 @@
</intent-filter>
</activity>
<!-- "Send as DM" share target. The android:name simple class ("ShareAsDMAlias")
is matched at runtime by ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME;
keep the two in sync when renaming. -->
<activity-alias
android:name=".ui.ShareAsDMAlias"
android:exported="true"
android:label="@string/share_target_as_dm"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
@@ -1888,8 +1888,8 @@ class Account(
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
): T {
val anonymousSigner = NostrSignerInternal(KeyPair())
val event = anonymousSigner.sign(template)
cache.justConsumeMyOwnEvent(event)
@@ -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,
)
}
}
@@ -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)
@@ -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 URIMIMESelectedMedia 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))
}
}
@@ -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<Route.GitRepository> { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
@@ -461,6 +462,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableArgs<Route.EventRedirect> { 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<Route.NewShortNote>(nav.controller)) return
if (isShareAsDm) {
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
} else {
if (isBaseRoute<Route.NewShortNote>(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<String?>(null) }
@@ -671,9 +691,17 @@ private fun NavigateIfIntentRequested(
val consumer =
Consumer<Intent> { 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<Route.NewShortNote>(nav.controller)) {
if (isShareAsDm) {
if (!isBaseRoute<Route.ShareToDM>(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<Route.NewShortNote>(nav.controller)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(Route.NewShortNote(message = it))
}
@@ -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 `<activity-alias>` 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
}
@@ -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(
@@ -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,
@@ -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()
@@ -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,
@@ -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(
@@ -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()
}
@@ -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<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun feed(): List<Note> {
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)
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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,
)
}
}
}
@@ -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<Long?>(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) {
+3
View File
@@ -1662,6 +1662,9 @@
<string name="copy_nprofile_to_clipboard">Copy nprofile to clipboard</string>
<string name="copy_npub_to_clipboard">Copy npub to clipboard</string>
<string name="share_or_save">Share or Save</string>
<string name="share_target_as_dm">Send as DM</string>
<string name="share_to_dm_title">Send to…</string>
<string name="share_to_dm_start_new">New message</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="add_media_to_gallery">Add Media to Gallery</string>
@@ -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"))
}
}
@@ -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)
}
}
@@ -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<TextNoteEvent>,
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)
}
}
@@ -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,
)
@@ -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)
}
}
@@ -1459,6 +1459,7 @@ fun MainContent(
}
},
activeColumnType = activeColumnType,
feedTabActive = activeColumnType is DeckColumnType.HomeFeed,
onShowImportFollowListDialog = onShowImportFollowListDialog,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
@@ -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
}
@@ -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)
}
@@ -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<String>,
@@ -165,6 +181,9 @@ fun FeedNoteCard(
onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
onHashtagClick: ((String) -> Unit)? = null,
followedUsers: Set<String> = 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<Event?>(null) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
// Inline expansion state — which note is expanded to show comments + related
var expandedNoteId by remember { mutableStateOf<String?>(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),
)
}
@@ -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)
}
@@ -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<LightboxState?>(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) },
)
}
}
}
}
@@ -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<List<DesktopScreen>>(emptyList())
val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _stack.asStateFlow()
private val _stack = mutableStateListOf<DesktopScreen>()
val stack: List<DesktopScreen> 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() },
)
}
}
}
}
@@ -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,
)
}
}
}
}
@@ -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,
@@ -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() },
)
}
}
}
}
@@ -101,6 +101,8 @@ fun NoteCard(
onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, 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) {
@@ -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)
}
@@ -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,
)
}
}
}
}
}
}
@@ -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()
}
}
}
@@ -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>(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),
)
}
}
}
@@ -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<String>,
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<List<CompactNoteData>>(
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<String>,
): List<CompactNoteData> {
val results = mutableListOf<Note>()
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,
)
}
}
}
}
@@ -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, 50150ms for ~30k
notes. No debounce needed; candidate-filter pre-check blocks 8090% 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<TextNoteEvent>? = null,
forkingFrom: EventHintBundle<TextNoteEvent>? = 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<TextNoteEvent>,
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<Note>()
// 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<Set<Note>>` (`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<List<CompactNoteData>>(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<List<CompactNoteData>>`. 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<TextNoteEvent>,
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<List<CompactNoteData>>(
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): ~50150ms for ~30k notes; fine on main.
- No debounce: candidate-filter blocks 8090% 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 ~10k50k 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<TextNoteEvent>` 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 Q1Q5 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.