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