mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
This commit is contained in:
@@ -218,6 +218,34 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- "Send as DM" share target. The android:name simple class ("ShareAsDMAlias")
|
||||
is matched at runtime by ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME;
|
||||
keep the two in sync when renaming. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsDMAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_dm"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_dm">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_dm">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_dm">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
|
||||
@@ -1888,8 +1888,8 @@ class Account(
|
||||
suspend fun <T : Event> signAnonymouslyAndBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
broadcast: List<Event> = emptyList(),
|
||||
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
|
||||
): T {
|
||||
val anonymousSigner = NostrSignerInternal(KeyPair())
|
||||
val event = anonymousSigner.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
@@ -66,6 +67,7 @@ class MultiOrchestrator(
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
convertGifToMp4: Boolean = false,
|
||||
forcedSigner: NostrSigner? = null,
|
||||
): Result {
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
@@ -84,6 +86,7 @@ class MultiOrchestrator(
|
||||
stripMetadata,
|
||||
onStrippingFailed,
|
||||
convertGifToMp4 = convertGifToMp4,
|
||||
forcedSigner = forcedSigner,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +109,7 @@ class MultiOrchestrator(
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
convertGifToMp4: Boolean = false,
|
||||
forcedSigner: NostrSigner? = null,
|
||||
): Result {
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
@@ -125,6 +129,7 @@ class MultiOrchestrator(
|
||||
stripMetadata,
|
||||
onStrippingFailed,
|
||||
convertGifToMp4 = convertGifToMp4,
|
||||
forcedSigner = forcedSigner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-6
@@ -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)
|
||||
|
||||
+42
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
|
||||
@@ -415,7 +416,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.GitRepository> { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
|
||||
@@ -461,6 +462,7 @@ fun BuildNavigation(
|
||||
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
|
||||
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
|
||||
|
||||
@@ -593,9 +595,15 @@ private fun NavigateIfIntentRequested(
|
||||
val activity = LocalContext.current.getActivity()
|
||||
|
||||
if (activity.intent.action == Intent.ACTION_SEND) {
|
||||
// avoids restarting the new Post screen when the intent is for the screen.
|
||||
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
|
||||
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
|
||||
if (isShareAsDm) {
|
||||
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
|
||||
} else {
|
||||
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
|
||||
}
|
||||
|
||||
// saves the intent to avoid processing again
|
||||
var message by remember {
|
||||
@@ -612,7 +620,19 @@ private fun NavigateIfIntentRequested(
|
||||
)
|
||||
}
|
||||
|
||||
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
|
||||
if (isShareAsDm) {
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
|
||||
} else {
|
||||
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
|
||||
}
|
||||
|
||||
// Consume the launch intent so a later recomposition can't re-fire
|
||||
// newStack for the same share (the isBaseRoute guard is a non-reactive
|
||||
// snapshot and stops guarding once we navigate past the destination,
|
||||
// e.g. into a chat via the one-shot picker). Clearing the action also
|
||||
// lets the else-branch register the onNewIntent listener for the rest
|
||||
// of this session.
|
||||
activity.intent.action = null
|
||||
} else {
|
||||
var newAccount by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -671,9 +691,17 @@ private fun NavigateIfIntentRequested(
|
||||
val consumer =
|
||||
Consumer<Intent> { intent ->
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
// avoids restarting the new Post screen when the intent is for the screen.
|
||||
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
|
||||
if (isShareAsDm) {
|
||||
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
|
||||
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
|
||||
val attachment =
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
|
||||
}
|
||||
} else if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
nav.newStack(Route.NewShortNote(message = it))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
/**
|
||||
* Distinguishes the "Send as DM" share target from the default "New Post" share
|
||||
* target. Both intent-filters resolve to MainActivity; they are told apart by the
|
||||
* component class name of the launching intent (the activity-alias name).
|
||||
*/
|
||||
object ShareIntentRouting {
|
||||
/**
|
||||
* Simple class name of the `<activity-alias>` declared in AndroidManifest.xml
|
||||
* (android:name=".ui.ShareAsDMAlias"). MUST stay in sync with the manifest —
|
||||
* renaming the alias there without updating this constant silently routes
|
||||
* "Send as DM" shares to the New Post composer (no build error).
|
||||
*/
|
||||
const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias"
|
||||
|
||||
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
|
||||
}
|
||||
+1
-1
@@ -234,7 +234,7 @@ fun routeToMessage(
|
||||
): Route {
|
||||
account.chatroomList.getOrCreatePrivateChatroom(room)
|
||||
|
||||
return Route.Room(room, draftMessage, replyId, draftId, expiresDays)
|
||||
return Route.Room(room, message = draftMessage, replyId = replyId, draftId = draftId, expiresDays = expiresDays)
|
||||
}
|
||||
|
||||
fun routeToMessage(
|
||||
|
||||
@@ -523,16 +523,23 @@ sealed class Route {
|
||||
val attachment: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ShareToDM(
|
||||
val message: String? = null,
|
||||
val attachment: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class Room(
|
||||
val id: String,
|
||||
val message: String? = null,
|
||||
val attachment: String? = null,
|
||||
val replyId: HexKey? = null,
|
||||
val draftId: HexKey? = null,
|
||||
val expiresDays: Int? = null,
|
||||
) : Route() {
|
||||
constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this(
|
||||
constructor(key: ChatroomKey, message: String? = null, attachment: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this(
|
||||
id = key.users.joinToString(","),
|
||||
message = message,
|
||||
attachment = attachment,
|
||||
replyId = replyId,
|
||||
draftId = draftId,
|
||||
expiresDays = expiresDays,
|
||||
|
||||
+14
-1
@@ -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()
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+19
-8
@@ -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(
|
||||
|
||||
+7
-1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
|
||||
/**
|
||||
* Recent private-DM conversations only (no public channels, ephemeral chats, or
|
||||
* marmot groups). Backs the Share-to-DM picker. Read-only/transient — extends the
|
||||
* non-additive [FeedFilter] base because the picker loads once and does not need
|
||||
* live additive updates.
|
||||
*/
|
||||
class ShareDMRoomsFeedFilter(
|
||||
val account: Account,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val chatList = account.chatroomList
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
return chatList.rooms
|
||||
.mapNotNull { key, chatroom ->
|
||||
if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) &&
|
||||
!account.isAllHidden(key.users)
|
||||
) {
|
||||
chatroom.newestMessage
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+108
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -91,7 +91,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
@@ -306,6 +309,14 @@ open class ShortNotePostViewModel :
|
||||
// Anonymous Reply
|
||||
var wantsAnonymousPost by mutableStateOf(false)
|
||||
|
||||
// A single ephemeral signer reused for the whole compose session so that media
|
||||
// uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed
|
||||
// by the same throwaway key, instead of leaking the real account's pubkey into the
|
||||
// upload authorization (and therefore into the returned media URL).
|
||||
private var anonymousSignerCache: NostrSigner? = null
|
||||
|
||||
fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it }
|
||||
|
||||
// Scheduled posting: epoch seconds (UTC) when the post should be published.
|
||||
// Null = post immediately on Send (existing behavior).
|
||||
var scheduledForSec by mutableStateOf<Long?>(null)
|
||||
@@ -870,7 +881,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
if (anonymous) {
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner())
|
||||
} else if (accountViewModel.settings.useTrackedBroadcasts()) {
|
||||
// Tracked broadcasting with progress feedback (non-blocking)
|
||||
val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast)
|
||||
@@ -1138,6 +1149,7 @@ open class ShortNotePostViewModel :
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
convertGifToMp4 = convertGifToMp4,
|
||||
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
@@ -1235,6 +1247,7 @@ open class ShortNotePostViewModel :
|
||||
wantsExclusiveGeoPost = false
|
||||
wantsSecretEmoji = false
|
||||
wantsAnonymousPost = false
|
||||
anonymousSignerCache = null
|
||||
scheduledForSec = null
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
@@ -1467,6 +1480,7 @@ open class ShortNotePostViewModel :
|
||||
account = account,
|
||||
context = appContext,
|
||||
useH265 = false,
|
||||
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
|
||||
)
|
||||
|
||||
when (result) {
|
||||
|
||||
@@ -1662,6 +1662,9 @@
|
||||
<string name="copy_nprofile_to_clipboard">Copy nprofile to clipboard</string>
|
||||
<string name="copy_npub_to_clipboard">Copy npub to clipboard</string>
|
||||
<string name="share_or_save">Share or Save</string>
|
||||
<string name="share_target_as_dm">Send as DM</string>
|
||||
<string name="share_to_dm_title">Send to…</string>
|
||||
<string name="share_to_dm_start_new">New message</string>
|
||||
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
|
||||
<string name="add_media_to_gallery">Add Media to Gallery</string>
|
||||
|
||||
+54
@@ -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"))
|
||||
}
|
||||
}
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.notify
|
||||
|
||||
/**
|
||||
* Pure event-building "verbs" for kind:1 short-note replies (NIP-10).
|
||||
*
|
||||
* Builds a signed [TextNoteEvent] reply but does NOT publish it. The Amethyst
|
||||
* Android UI flow does more than these builders — non-UI callers are
|
||||
* responsible for the rest:
|
||||
*
|
||||
* * **Publish.** Hand the returned event to your relay client. Android uses
|
||||
* `Account.sendMyPublicAndPrivateOutbox`, the desktop deck pipes through
|
||||
* `dispatch(signed, localCache, relayManager)`, amy uses `Context.publish`.
|
||||
* * **Writeable check.** Skip the call when the active signer is read-only
|
||||
* (e.g. an npub-only login). Building will fail at the sign step otherwise.
|
||||
* * **Parent kind.** Only kind:1 [TextNoteEvent] parents are well-defined here
|
||||
* — replies to articles / comments belong on the NIP-22 path
|
||||
* (`CommentEvent.replyBuilder`). Callers must filter; this signature enforces
|
||||
* it via [EventHintBundle] of `TextNoteEvent`.
|
||||
* * **Local cache update.** If your caller has a local event cache, feed the
|
||||
* new event back in so the UI / next read sees the update without a relay
|
||||
* round-trip.
|
||||
*
|
||||
* Canonical entry point for non-UI callers — the underlying
|
||||
* [TextNoteEvent.build] reply-aware overload handles full NIP-10 tag carry:
|
||||
* `marker=root` (parent's root e-tag if present, else parent.id),
|
||||
* `marker=reply` (parent.id), the parent's full p-tag chain plus parent.pubKey,
|
||||
* and the relay hint from [EventHintBundle].
|
||||
*/
|
||||
object ReplyActions {
|
||||
/**
|
||||
* Build a kind:1 [TextNoteEvent] that replies to [parent], wrapping it with
|
||||
* NIP-10-correct marked e-tags and the parent's p-tag chain.
|
||||
*
|
||||
* Returns the signed event ready to be published. The reply preserves the
|
||||
* parent's root reference so conformant clients can reconstruct the thread.
|
||||
*/
|
||||
suspend fun replyTo(
|
||||
parent: EventHintBundle<TextNoteEvent>,
|
||||
content: String,
|
||||
signer: NostrSigner,
|
||||
): TextNoteEvent {
|
||||
// Per NIP-10, replies MUST carry the p-tags of the event being replied
|
||||
// to plus the author's pubkey. TextNoteEvent.build(replyingTo=) only
|
||||
// emits the e-tag chain — p-tag carry is the caller's responsibility.
|
||||
val carriedPubKeys =
|
||||
(parent.event.linkedPubKeys() + parent.event.pubKey)
|
||||
.distinct()
|
||||
.map { PTag(it, relayHint = null) }
|
||||
|
||||
val template =
|
||||
TextNoteEvent.build(content, replyingTo = parent) {
|
||||
notify(carriedPubKeys)
|
||||
}
|
||||
return signer.sign(template)
|
||||
}
|
||||
}
|
||||
+36
@@ -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,
|
||||
)
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReplyActionsTest {
|
||||
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
|
||||
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
|
||||
|
||||
private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008"
|
||||
private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray()))
|
||||
|
||||
@Test
|
||||
fun replyToTopLevelParent_setsRootToParentAndCarriesAuthor() =
|
||||
runTest {
|
||||
// Alice posts a top-level note (no e-tags = parent IS its own root).
|
||||
val parent = aliceSigner.sign(TextNoteEvent.build("hello"))
|
||||
assertTrue(parent.isNewThread(), "parent must be a fresh thread for this case")
|
||||
|
||||
// Bob replies.
|
||||
val reply = ReplyActions.replyTo(EventHintBundle(parent, null), "hi alice", bobSigner)
|
||||
|
||||
assertEquals(TextNoteEvent.KIND, reply.kind)
|
||||
assertEquals(bobSigner.pubKey, reply.pubKey)
|
||||
|
||||
// Per `prepareETagsAsReplyTo`: when parent has no root, only a ROOT
|
||||
// marker is emitted (it doubles as the reply target). No separate
|
||||
// REPLY marker. `markedReplyTos()` should still resolve to parent.id.
|
||||
val root = reply.markedRoot()
|
||||
assertNotNull(root, "reply must carry a NIP-10 root marker")
|
||||
assertEquals(parent.id, root.eventId, "root marker must point at the top-level parent")
|
||||
|
||||
// p-tag carry must include the parent's author so they're notified.
|
||||
val pubKeys = reply.tags.mapNotNull(PTag::parseKey)
|
||||
assertTrue(parent.pubKey in pubKeys, "reply must carry the parent's pubkey in p-tags")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyToDeepThread_carriesRootForwardAndChainsPTags() =
|
||||
runTest {
|
||||
// Build A (root) → B (alice's reply to A) → C (carol's reply to B).
|
||||
val a = aliceSigner.sign(TextNoteEvent.build("the original"))
|
||||
|
||||
val carolPriv = "0000000000000000000000000000000000000000000000000000000000000009"
|
||||
val carolSigner = NostrSignerInternal(KeyPair(carolPriv.hexToByteArray()))
|
||||
|
||||
val b = ReplyActions.replyTo(EventHintBundle(a, null), "good point", aliceSigner)
|
||||
|
||||
// C replies to B — must carry A as root (not B), and reply to B.
|
||||
val c = ReplyActions.replyTo(EventHintBundle(b, null), "agreed", carolSigner)
|
||||
|
||||
val rootC = c.markedRoot()
|
||||
assertNotNull(rootC, "deep reply must carry root marker")
|
||||
assertEquals(a.id, rootC.eventId, "deep reply's root must chain through to original")
|
||||
|
||||
val replyC = c.markedReply()
|
||||
assertNotNull(replyC, "deep reply must carry reply marker")
|
||||
assertEquals(b.id, replyC.eventId, "deep reply's reply marker must point at immediate parent")
|
||||
|
||||
// p-tag chain: must include both alice (root author / parent author) and parent.pubKey.
|
||||
val pubKeys = c.tags.mapNotNull(PTag::parseKey).toSet()
|
||||
assertTrue(aliceSigner.pubKey in pubKeys, "deep reply must carry root author in p-tags")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyEvent_isSignedAndKind1() =
|
||||
runTest {
|
||||
val parent = aliceSigner.sign(TextNoteEvent.build("seed"))
|
||||
val reply = ReplyActions.replyTo(EventHintBundle(parent, null), "thanks", bobSigner)
|
||||
|
||||
assertEquals(TextNoteEvent.KIND, reply.kind)
|
||||
assertTrue(reply.id.length == 64, "reply id must be a 32-byte hex")
|
||||
assertTrue(reply.sig.length == 128, "reply must be signed (64-byte sig hex)")
|
||||
assertEquals("thanks", reply.content)
|
||||
}
|
||||
}
|
||||
@@ -1459,6 +1459,7 @@ fun MainContent(
|
||||
}
|
||||
},
|
||||
activeColumnType = activeColumnType,
|
||||
feedTabActive = activeColumnType is DeckColumnType.HomeFeed,
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
}
|
||||
|
||||
+43
@@ -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)
|
||||
}
|
||||
+361
-62
@@ -82,9 +82,12 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.actions.ReplyActions
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
|
||||
import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||
@@ -96,10 +99,12 @@ import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.cache.dispatch
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
||||
@@ -115,6 +120,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscrip
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
@@ -122,10 +128,17 @@ import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.CommentItem
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.CommentsCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.InlineReplyInput
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.RelatedContentSection
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
@@ -137,6 +150,9 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class LightboxState(
|
||||
val urls: List<String>,
|
||||
@@ -165,6 +181,9 @@ fun FeedNoteCard(
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
||||
onHashtagClick: ((String) -> Unit)? = null,
|
||||
followedUsers: Set<String> = emptySet(),
|
||||
myPubKeyHex: String? = null,
|
||||
onFollow: ((String) -> Unit)? = null,
|
||||
) {
|
||||
val event = note.event ?: return
|
||||
val isRepost = event is RepostEvent || event is GenericRepostEvent
|
||||
@@ -226,8 +245,13 @@ fun FeedNoteCard(
|
||||
BoostedMark()
|
||||
}
|
||||
|
||||
// Original note content
|
||||
// Original note content with actions inside card
|
||||
val displayData = remember(originalEvent, metadataState) { originalEvent.toNoteDisplayData(localCache) }
|
||||
val showRepostFollowPill =
|
||||
account != null &&
|
||||
onFollow != null &&
|
||||
originalEvent.pubKey != myPubKeyHex &&
|
||||
originalEvent.pubKey !in followedUsers
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -238,30 +262,41 @@ fun FeedNoteCard(
|
||||
onHashtagClick = onHashtagClick,
|
||||
onImageClick = onImageClick,
|
||||
onMediaClick = onMediaClick,
|
||||
headerTrailingContent =
|
||||
if (showRepostFollowPill) {
|
||||
{
|
||||
FollowPill(onClick = { onFollow.invoke(originalEvent.pubKey) })
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
bottomContent =
|
||||
if (account != null) {
|
||||
{
|
||||
NoteActionsRow(
|
||||
event = originalEvent,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
note = originalNote,
|
||||
zapCount = originalNote.zaps.size,
|
||||
zapAmountSats = zapAmount.toLong(),
|
||||
zapReceipts = emptyList(),
|
||||
reactionCount = reactionCount,
|
||||
replyCount = replyCount,
|
||||
repostCount = repostCount,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
// Action buttons for original note
|
||||
if (account != null) {
|
||||
NoteActionsRow(
|
||||
event = originalEvent,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
note = originalNote,
|
||||
zapCount = originalNote.zaps.size,
|
||||
zapAmountSats = zapAmount.toLong(),
|
||||
zapReceipts = emptyList(),
|
||||
reactionCount = reactionCount,
|
||||
replyCount = replyCount,
|
||||
repostCount = repostCount,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular note rendering
|
||||
@@ -280,42 +315,57 @@ fun FeedNoteCard(
|
||||
onDispose { note.clearFlow() }
|
||||
}
|
||||
|
||||
Column {
|
||||
val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) }
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onHashtagClick = onHashtagClick,
|
||||
onImageClick = onImageClick,
|
||||
onMediaClick = onMediaClick,
|
||||
)
|
||||
|
||||
if (account != null) {
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
note = note,
|
||||
zapCount = note.zaps.size,
|
||||
zapAmountSats = zapAmount.toLong(),
|
||||
zapReceipts = emptyList(),
|
||||
reactionCount = reactionCount,
|
||||
replyCount = replyCount,
|
||||
repostCount = repostCount,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) }
|
||||
val showFollowPill =
|
||||
account != null &&
|
||||
onFollow != null &&
|
||||
event.pubKey != myPubKeyHex &&
|
||||
event.pubKey !in followedUsers
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onHashtagClick = onHashtagClick,
|
||||
onImageClick = onImageClick,
|
||||
onMediaClick = onMediaClick,
|
||||
headerTrailingContent =
|
||||
if (showFollowPill) {
|
||||
{
|
||||
FollowPill(onClick = { onFollow.invoke(event.pubKey) })
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
bottomContent =
|
||||
if (account != null) {
|
||||
{
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
note = note,
|
||||
zapCount = note.zaps.size,
|
||||
zapAmountSats = zapAmount.toLong(),
|
||||
zapReceipts = emptyList(),
|
||||
reactionCount = reactionCount,
|
||||
replyCount = replyCount,
|
||||
repostCount = repostCount,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +406,25 @@ fun FeedScreen(
|
||||
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
|
||||
// Inline expansion state — which note is expanded to show comments + related
|
||||
var expandedNoteId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Follow pill state
|
||||
val scope = rememberCoroutineScope()
|
||||
val followMutex = remember { Mutex() }
|
||||
val onFollowFromFeed: (String) -> Unit = { pubKeyHex ->
|
||||
if (account != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
followMutex.withLock {
|
||||
val currentList = localCache.lastContactListEvent
|
||||
val updatedEvent = FollowAction.follow(pubKeyHex, account.signer, currentList)
|
||||
// consume updates followedUsers StateFlow + stores the event before broadcast
|
||||
dispatch(updatedEvent, localCache, relayManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
var activeFeedId by remember { mutableStateOf(customFeedId) }
|
||||
var activeFeedSource by remember {
|
||||
@@ -465,7 +534,7 @@ fun FeedScreen(
|
||||
// Force refresh when followedUsers arrives and feed is still empty
|
||||
LaunchedEffect(followedUsers, feedState) {
|
||||
if (followedUsers.isNotEmpty() && feedState is FeedState.Empty) {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) {
|
||||
viewModel.feedState.refreshSuspended()
|
||||
}
|
||||
}
|
||||
@@ -686,6 +755,8 @@ fun FeedScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(loadedState.list, key = { it.idHex }) { note ->
|
||||
val isExpanded = note.idHex == expandedNoteId
|
||||
|
||||
FeedNoteCard(
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
@@ -695,7 +766,10 @@ fun FeedScreen(
|
||||
onReply = { replyToEvent = note.event },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToThread = { noteId ->
|
||||
// Toggle inline expansion instead of navigating
|
||||
expandedNoteId = if (expandedNoteId == noteId) null else noteId
|
||||
},
|
||||
onImageClick = { urls, index ->
|
||||
lightboxState = LightboxState(urls, index)
|
||||
},
|
||||
@@ -705,7 +779,33 @@ fun FeedScreen(
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
followedUsers = followedUsers,
|
||||
myPubKeyHex = account?.pubKeyHex,
|
||||
onFollow = onFollowFromFeed,
|
||||
)
|
||||
|
||||
// Inline expanded content: CommentsCard + Related
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
ExpandedNoteContent(
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = { noteId ->
|
||||
expandedNoteId = if (expandedNoteId == noteId) null else noteId
|
||||
},
|
||||
onNavigateToThreadOverlay = onNavigateToThread,
|
||||
onReply = { replyToEvent = it },
|
||||
onZapFeedback = onZapFeedback,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1376,3 +1476,202 @@ private fun FeedHeader(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandedNoteContent(
|
||||
note: com.vitorpamplona.amethyst.commons.model.Note,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn?,
|
||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onNavigateToThreadOverlay: (String) -> Unit = {},
|
||||
onReply: (Event) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val event = note.event ?: return
|
||||
val noteId = event.id
|
||||
val expandedScope = rememberCoroutineScope()
|
||||
val connectedRelays =
|
||||
relayManager.relayStatuses
|
||||
.collectAsState()
|
||||
.value.keys
|
||||
|
||||
// Subscribe for replies when expanded
|
||||
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createThreadRepliesSubscription(
|
||||
relays = connectedRelays,
|
||||
noteId = noteId,
|
||||
onEvent = { ev, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(ev, relay)
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Observe replies flow so we recompose when new replies arrive
|
||||
val noteFlowSet = remember(note) { note.flow() }
|
||||
val repliesState by noteFlowSet.replies.stateFlow.collectAsState()
|
||||
|
||||
DisposableEffect(note) { onDispose { note.clearFlow() } }
|
||||
|
||||
// Get reply notes from cache — recompute when replies change
|
||||
val replyNotes = remember(repliesState) { note.replies.sortedByDescending { it.createdAt() } }
|
||||
|
||||
// Load metadata for reply authors
|
||||
LaunchedEffect(replyNotes, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null && replyNotes.isNotEmpty()) {
|
||||
val authors = replyNotes.mapNotNull { it.event?.pubKey }.distinct()
|
||||
if (authors.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataBatched(authors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
// Comments card
|
||||
CommentsCard(
|
||||
commentCount = replyNotes.size,
|
||||
replyContent = {
|
||||
if (account != null) {
|
||||
val myUser = remember(account.pubKeyHex) { localCache.getUserIfExists(account.pubKeyHex) }
|
||||
val myAvatarUrl = remember(myUser) { myUser?.profilePicture() }
|
||||
|
||||
InlineReplyInput(
|
||||
myAvatarUrl = myAvatarUrl,
|
||||
onSend = { content ->
|
||||
withContext(Dispatchers.IO) {
|
||||
val parentText = event as? TextNoteEvent ?: return@withContext
|
||||
val signedEvent =
|
||||
ReplyActions.replyTo(
|
||||
EventHintBundle(parentText, null),
|
||||
content,
|
||||
account.signer,
|
||||
)
|
||||
dispatch(signedEvent, localCache, relayManager)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (replyNotes.isEmpty()) {
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
} else {
|
||||
replyNotes.take(5).forEachIndexed { index, replyNote ->
|
||||
val replyEvent = replyNote.event
|
||||
val flowSet = remember(replyNote) { replyNote.flow() }
|
||||
val metadataState by flowSet.metadata.stateFlow.collectAsState()
|
||||
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
|
||||
val zapsState by flowSet.zaps.stateFlow.collectAsState()
|
||||
|
||||
DisposableEffect(replyNote) { onDispose { replyNote.clearFlow() } }
|
||||
|
||||
val author =
|
||||
remember(replyEvent?.pubKey, metadataState) {
|
||||
replyEvent?.pubKey?.let { localCache.getUserIfExists(it) }
|
||||
}
|
||||
val reactionCount = remember(reactionsState) { replyNote.countReactions() }
|
||||
val zapAmount = remember(zapsState) { replyNote.zapsAmount }
|
||||
|
||||
CommentItem(
|
||||
authorName = author?.toBestDisplayName() ?: replyEvent?.pubKey?.take(8) ?: "",
|
||||
authorHandle = author?.pubkeyNpub()?.take(16)?.let { "@$it..." } ?: "",
|
||||
authorAvatarUrl = author?.profilePicture(),
|
||||
authorPubKeyHex = replyEvent?.pubKey ?: "",
|
||||
content = replyEvent?.content ?: "",
|
||||
timeAgo = (replyEvent?.createdAt ?: 0L).toTimeAgo(),
|
||||
reactionCount = reactionCount,
|
||||
zapAmount = zapAmount.toLong(),
|
||||
onReply = { replyNote.event?.let { onReply(it) } },
|
||||
onLike = {
|
||||
val ev = replyNote.event
|
||||
if (account != null && ev != null) {
|
||||
expandedScope.launch(Dispatchers.IO) {
|
||||
val signed =
|
||||
ReactionAction.reactTo(
|
||||
EventHintBundle(ev, null),
|
||||
"+",
|
||||
account.signer,
|
||||
)
|
||||
dispatch(signed, localCache, relayManager)
|
||||
}
|
||||
}
|
||||
},
|
||||
onZap = {
|
||||
val ev = replyNote.event
|
||||
if (account != null && ev != null && nwcConnection != null) {
|
||||
expandedScope.launch {
|
||||
val feedback =
|
||||
zapNote(
|
||||
event = ev,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
amountSats = 21,
|
||||
nwcConnection = nwcConnection,
|
||||
)
|
||||
onZapFeedback(feedback)
|
||||
}
|
||||
}
|
||||
},
|
||||
onAuthorClick = { replyNote.event?.pubKey?.let { onNavigateToProfile(it) } },
|
||||
)
|
||||
if (index < replyNotes.take(5).lastIndex) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Related content
|
||||
val noteHashtags =
|
||||
remember(event) {
|
||||
event.tags.mapNotNull(HashtagTag::parse).toSet()
|
||||
}
|
||||
RelatedContentSection(
|
||||
noteId = noteId,
|
||||
authorPubKey = event.pubKey,
|
||||
noteHashtags = noteHashtags,
|
||||
localCache = localCache,
|
||||
onItemClick = onNavigateToThreadOverlay,
|
||||
onViewAll = { onNavigateToProfile(event.pubKey) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowPill(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = onClick,
|
||||
label = {
|
||||
Text(
|
||||
"Follow",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
MaterialSymbols.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
},
|
||||
modifier = modifier.height(28.dp),
|
||||
)
|
||||
}
|
||||
|
||||
+21
-63
@@ -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)
|
||||
}
|
||||
|
||||
+147
-47
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,20 +44,24 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.actions.ReplyActions
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.cache.dispatch
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
@@ -70,11 +72,21 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubsc
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.CommentItem
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.CommentsCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.InlineReplyInput
|
||||
import com.vitorpamplona.amethyst.desktop.ui.thread.RelatedContentSection
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Desktop Thread Screen - displays a note and all its replies in a thread view.
|
||||
@@ -98,6 +110,7 @@ fun ThreadScreen(
|
||||
) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays = relayStatuses.keys
|
||||
val threadScope = rememberCoroutineScope()
|
||||
|
||||
// Lightbox state
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
@@ -311,54 +324,141 @@ fun ThreadScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Reply notes with level indicators
|
||||
items(replyNotes, key = { it.idHex }) { note ->
|
||||
val level = calculateLevel(note)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawReplyLevel(
|
||||
level = level,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
selected = MaterialTheme.colorScheme.outlineVariant,
|
||||
).clickable {
|
||||
note.event?.let { onNavigateToThread(it.id) }
|
||||
},
|
||||
// Comments card (replies + inline reply input)
|
||||
item(key = "comments-card") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
CommentsCard(
|
||||
commentCount = replyNotes.size,
|
||||
replyContent = {
|
||||
if (account != null && rootNote != null) {
|
||||
val myPubKey = account.pubKeyHex
|
||||
val myUser =
|
||||
remember(myPubKey) { localCache.getUserIfExists(myPubKey) }
|
||||
val myAvatarUrl = remember(myUser) { myUser?.profilePicture() }
|
||||
|
||||
InlineReplyInput(
|
||||
myAvatarUrl = myAvatarUrl,
|
||||
onSend = { content ->
|
||||
withContext(Dispatchers.IO) {
|
||||
val parentText =
|
||||
rootNote.event as? TextNoteEvent
|
||||
?: return@withContext
|
||||
val signedEvent =
|
||||
ReplyActions.replyTo(
|
||||
EventHintBundle(parentText, null),
|
||||
content,
|
||||
account.signer,
|
||||
)
|
||||
dispatch(signedEvent, localCache, relayManager)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
FeedNoteCard(
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { note.event?.let { onReply(it) } },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index ->
|
||||
lightboxState = LightboxState(urls, index)
|
||||
},
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
if (replyNotes.isEmpty()) {
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
} else {
|
||||
replyNotes.forEachIndexed { index, note ->
|
||||
val event = note.event
|
||||
|
||||
// Observe metadata + reactions so we recompose
|
||||
// when author info arrives from relays
|
||||
val flowSet = remember(note) { note.flow() }
|
||||
val metadataState by flowSet.metadata.stateFlow.collectAsState()
|
||||
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
|
||||
val zapsState by flowSet.zaps.stateFlow.collectAsState()
|
||||
|
||||
DisposableEffect(note) { onDispose { note.clearFlow() } }
|
||||
|
||||
val author =
|
||||
remember(event?.pubKey, metadataState) {
|
||||
event?.pubKey?.let { localCache.getUserIfExists(it) }
|
||||
}
|
||||
|
||||
val reactionCount =
|
||||
remember(reactionsState) { note.countReactions() }
|
||||
val zapAmount = remember(zapsState) { note.zapsAmount }
|
||||
|
||||
CommentItem(
|
||||
authorName =
|
||||
author?.toBestDisplayName()
|
||||
?: event?.pubKey?.take(8)
|
||||
?: "",
|
||||
authorHandle =
|
||||
author?.pubkeyNpub()?.take(16)?.let { "@$it..." }
|
||||
?: "",
|
||||
authorAvatarUrl = author?.profilePicture(),
|
||||
authorPubKeyHex = event?.pubKey ?: "",
|
||||
content = event?.content ?: "",
|
||||
timeAgo = (event?.createdAt ?: 0L).toTimeAgo(),
|
||||
reactionCount = reactionCount,
|
||||
zapAmount = zapAmount.toLong(),
|
||||
onReply = { note.event?.let { onReply(it) } },
|
||||
onLike = {
|
||||
val ev = note.event
|
||||
if (account != null && ev != null) {
|
||||
threadScope.launch(Dispatchers.IO) {
|
||||
val signed =
|
||||
ReactionAction.reactTo(
|
||||
EventHintBundle(ev, null),
|
||||
"+",
|
||||
account.signer,
|
||||
)
|
||||
dispatch(signed, localCache, relayManager)
|
||||
}
|
||||
}
|
||||
},
|
||||
onZap = {
|
||||
val ev = note.event
|
||||
if (account != null && ev != null && nwcConnection != null) {
|
||||
threadScope.launch {
|
||||
zapNote(
|
||||
event = ev,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
amountSats = 21,
|
||||
nwcConnection = nwcConnection,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onAuthorClick = {
|
||||
note.event?.pubKey?.let { onNavigateToProfile(it) }
|
||||
},
|
||||
)
|
||||
if (index < replyNotes.lastIndex) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
|
||||
// Empty/loading state for replies
|
||||
if (replyNotes.isEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
// Related content section
|
||||
if (rootNote != null) {
|
||||
item(key = "related-content") {
|
||||
val rootEvent = rootNote.event
|
||||
if (rootEvent != null) {
|
||||
val noteHashtags =
|
||||
remember(rootEvent) {
|
||||
rootEvent.tags.hashtags().toSet()
|
||||
}
|
||||
RelatedContentSection(
|
||||
noteId = noteId,
|
||||
authorPubKey = rootEvent.pubKey,
|
||||
noteHashtags = noteHashtags,
|
||||
localCache = localCache,
|
||||
onItemClick = onNavigateToThread,
|
||||
onViewAll = { onNavigateToProfile(rootEvent.pubKey) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-37
@@ -20,6 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.deck
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -29,11 +37,22 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopScreen
|
||||
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
|
||||
@@ -65,25 +84,38 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class ColumnNavigationState {
|
||||
private val _stack = MutableStateFlow<List<DesktopScreen>>(emptyList())
|
||||
val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _stack.asStateFlow()
|
||||
private val _stack = mutableStateListOf<DesktopScreen>()
|
||||
val stack: List<DesktopScreen> get() = _stack
|
||||
val current: DesktopScreen? get() = _stack.lastOrNull()
|
||||
val hasBackStack: Boolean get() = _stack.isNotEmpty()
|
||||
|
||||
var navigatingForward by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
fun pushWithCap(
|
||||
screen: DesktopScreen,
|
||||
maxDepth: Int = 2,
|
||||
) {
|
||||
navigatingForward = true
|
||||
if (_stack.size >= maxDepth) _stack.removeFirst()
|
||||
_stack.add(screen)
|
||||
}
|
||||
|
||||
fun push(screen: DesktopScreen) {
|
||||
_stack.value = _stack.value + screen
|
||||
pushWithCap(screen)
|
||||
}
|
||||
|
||||
fun pop(): Boolean {
|
||||
if (_stack.value.isEmpty()) return false
|
||||
_stack.value = _stack.value.dropLast(1)
|
||||
if (_stack.isEmpty()) return false
|
||||
navigatingForward = false
|
||||
_stack.removeLast()
|
||||
return true
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_stack.value = emptyList()
|
||||
_stack.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +143,38 @@ fun DeckColumnContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val navState = remember(column.id) { ColumnNavigationState() }
|
||||
val navStack by navState.stack.collectAsState()
|
||||
val currentOverlay = navStack.lastOrNull()
|
||||
val currentOverlay = navState.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// Request focus once when the column is created. Re-keying on
|
||||
// `currentOverlay` would steal focus from sibling columns whenever any
|
||||
// deck column mutates its overlay state (e.g. typing in column A's reply
|
||||
// box loses focus when column B opens a profile). Esc continues to work
|
||||
// because the column still owns focus when the user hits the key.
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.width(column.width.dp)
|
||||
.fillMaxHeight(),
|
||||
.fillMaxHeight()
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.key == Key.Escape && event.type == KeyEventType.KeyUp && navState.hasBackStack) {
|
||||
navState.pop()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
ColumnHeader(
|
||||
column = column,
|
||||
canClose = canClose,
|
||||
hasBackStack = navStack.isNotEmpty(),
|
||||
hasBackStack = navState.hasBackStack,
|
||||
onBack = { navState.pop() },
|
||||
onClose = onClose,
|
||||
onDoubleClick = onDoubleClickHeader,
|
||||
@@ -147,10 +198,8 @@ fun DeckColumnContainer(
|
||||
)
|
||||
|
||||
// Content runs edge-to-edge; each screen adds its own header padding
|
||||
// to match the Messages pattern (padding(horizontal = 12, vertical = 8)
|
||||
// on the title row, no outer wrapper).
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||
// Always keep RootContent composed so state survives navigation
|
||||
RootContent(
|
||||
columnType = column.type,
|
||||
relayManager = relayManager,
|
||||
@@ -174,28 +223,75 @@ fun DeckColumnContainer(
|
||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
)
|
||||
if (currentOverlay != null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
OverlayContent(
|
||||
screen = currentOverlay,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||
onBack = { navState.pop() },
|
||||
)
|
||||
|
||||
// Overlay with slide animation
|
||||
AnimatedContent(
|
||||
targetState = currentOverlay,
|
||||
transitionSpec = {
|
||||
val duration = 200
|
||||
if (navState.navigatingForward) {
|
||||
(
|
||||
slideInHorizontally(
|
||||
tween(duration),
|
||||
) { it } +
|
||||
fadeIn(
|
||||
androidx.compose.animation.core
|
||||
.tween(duration),
|
||||
)
|
||||
).togetherWith(
|
||||
slideOutHorizontally(
|
||||
tween(duration),
|
||||
) { -it } +
|
||||
fadeOut(
|
||||
androidx.compose.animation.core
|
||||
.tween(duration),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
slideInHorizontally(
|
||||
tween(duration),
|
||||
) { -it } +
|
||||
fadeIn(
|
||||
androidx.compose.animation.core
|
||||
.tween(duration),
|
||||
)
|
||||
).togetherWith(
|
||||
slideOutHorizontally(
|
||||
tween(duration),
|
||||
) { it } +
|
||||
fadeOut(
|
||||
androidx.compose.animation.core
|
||||
.tween(duration),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "ColumnNavAnimation",
|
||||
) { overlayScreen ->
|
||||
if (overlayScreen != null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
OverlayContent(
|
||||
screen = overlayScreen,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||
onBack = { navState.pop() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-21
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-9
@@ -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,
|
||||
|
||||
+45
-24
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-8
@@ -101,6 +101,8 @@ fun NoteCard(
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
||||
onPayInvoice: ((String) -> Unit)? = null,
|
||||
bottomContent: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
headerTrailingContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
|
||||
val imageUrls =
|
||||
@@ -161,21 +163,25 @@ fun NoteCard(
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Author with avatar — stadium-shaped hover to match the
|
||||
// avatar+name chip's visual shape.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
if (onAuthorClick != null) {
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.clickable { onAuthorClick(note.pubKeyHex) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.then(
|
||||
if (onAuthorClick != null) {
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.clickable { onAuthorClick(note.pubKeyHex) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = note.pubKeyHex,
|
||||
@@ -194,6 +200,10 @@ fun NoteCard(
|
||||
)
|
||||
}
|
||||
|
||||
if (headerTrailingContent != null) {
|
||||
headerTrailingContent()
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = note.createdAt,
|
||||
@@ -306,6 +316,10 @@ fun NoteCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bottomContent != null) {
|
||||
bottomContent()
|
||||
}
|
||||
}
|
||||
|
||||
if (onClick != null) {
|
||||
|
||||
+116
@@ -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)
|
||||
}
|
||||
+165
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.thread
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
import androidx.compose.ui.input.key.isMetaPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface SendState {
|
||||
data object Idle : SendState
|
||||
|
||||
data object Sending : SendState
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : SendState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InlineReplyInput(
|
||||
myAvatarUrl: String?,
|
||||
onSend: suspend (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
var sendState by remember { mutableStateOf<SendState>(SendState.Idle) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isSending = sendState is SendState.Sending
|
||||
|
||||
fun doSend() {
|
||||
val content = text.trim()
|
||||
if (content.isEmpty() || isSending) return
|
||||
sendState = SendState.Sending
|
||||
scope.launch {
|
||||
try {
|
||||
onSend(content)
|
||||
text = ""
|
||||
sendState = SendState.Idle
|
||||
} catch (e: Exception) {
|
||||
sendState = SendState.Error(e.message ?: "Failed to send reply")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = "",
|
||||
pictureUrl = myAvatarUrl,
|
||||
size = 32.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = {
|
||||
text = it
|
||||
// Clear error on new input
|
||||
if (sendState is SendState.Error) sendState = SendState.Idle
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
"Add a comment...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.Enter) {
|
||||
val modifierHeld =
|
||||
if (PlatformInfo.isMacOS) {
|
||||
keyEvent.isMetaPressed
|
||||
} else {
|
||||
keyEvent.isCtrlPressed
|
||||
}
|
||||
if (modifierHeld) {
|
||||
doSend()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
singleLine = false,
|
||||
maxLines = 5,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { doSend() },
|
||||
enabled = text.isNotBlank() && !isSending,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier = Modifier.height(36.dp),
|
||||
) {
|
||||
if (isSending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
MaterialSymbols.AutoMirrored.Send,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Send", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
val error = sendState
|
||||
if (error is SendState.Error) {
|
||||
Text(
|
||||
text = error.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(start = 40.dp, top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.thread
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.feeds.related.CompactNoteData
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.util.showAmount
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Horizontal scrollable row of compact related content cards.
|
||||
* Shows related posts by hashtag and author for the given note.
|
||||
* Only loads when the thread view is open.
|
||||
*/
|
||||
@Composable
|
||||
fun RelatedContentSection(
|
||||
noteId: String,
|
||||
authorPubKey: String,
|
||||
noteHashtags: Set<String>,
|
||||
localCache: DesktopLocalCache,
|
||||
onItemClick: (String) -> Unit,
|
||||
onViewAll: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet()
|
||||
|
||||
// Re-scan the cache initially and whenever a bundle of new events arrives
|
||||
// that contains a candidate (same hashtag or same author). Without this,
|
||||
// expanding a note on a cold cache leaves the section empty until the user
|
||||
// collapses + re-expands.
|
||||
val relatedItems by produceState<List<CompactNoteData>>(
|
||||
initialValue = emptyList(),
|
||||
key1 = noteId,
|
||||
key2 = authorPubKey,
|
||||
key3 = lowercaseTags,
|
||||
) {
|
||||
fun rescan() {
|
||||
runCatching {
|
||||
value = scanRelated(localCache, noteId, authorPubKey, lowercaseTags)
|
||||
}
|
||||
}
|
||||
rescan()
|
||||
localCache.eventStream.newEventBundles.collect { bundle ->
|
||||
val matters =
|
||||
bundle.any { n ->
|
||||
val ev = n.event
|
||||
ev is TextNoteEvent &&
|
||||
n.idHex != noteId &&
|
||||
(
|
||||
ev.pubKey == authorPubKey ||
|
||||
(lowercaseTags.isNotEmpty() && ev.tags.isTaggedHashes(lowercaseTags))
|
||||
)
|
||||
}
|
||||
if (matters) rescan()
|
||||
}
|
||||
}
|
||||
|
||||
if (relatedItems.isNotEmpty()) {
|
||||
val primaryHashtag = noteHashtags.firstOrNull()
|
||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (primaryHashtag != null) {
|
||||
Row {
|
||||
Text(
|
||||
text = "Related from ",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = "#$primaryHashtag",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "More from this author",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "View all >",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable(onClick = onViewAll),
|
||||
)
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(relatedItems, key = { it.id }) { item ->
|
||||
CompactRelatedCard(
|
||||
item = item,
|
||||
onClick = { onItemClick(item.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val RELATED_LIMIT = 6
|
||||
|
||||
/**
|
||||
* Scan the local cache for notes related to [noteId] either by sharing a
|
||||
* hashtag in [lowercaseTags] or by being authored by [authorPubKey]. Returns
|
||||
* up to [RELATED_LIMIT] notes, most recent first, mapped to [CompactNoteData].
|
||||
*
|
||||
* Runs O(N) over `localCache.notes` — backed by `ConcurrentSkipListMap` which
|
||||
* supports concurrent inserts during iteration (weakly consistent). Safe on
|
||||
* the main composition coroutine for typical cache sizes (~30k notes).
|
||||
*/
|
||||
private fun scanRelated(
|
||||
localCache: DesktopLocalCache,
|
||||
noteId: String,
|
||||
authorPubKey: String,
|
||||
lowercaseTags: Set<String>,
|
||||
): List<CompactNoteData> {
|
||||
val results = mutableListOf<Note>()
|
||||
|
||||
if (lowercaseTags.isNotEmpty()) {
|
||||
localCache.notes.forEach { _, note ->
|
||||
if (note.idHex != noteId &&
|
||||
note.event is TextNoteEvent &&
|
||||
note.event?.tags?.isTaggedHashes(lowercaseTags) == true
|
||||
) {
|
||||
results.add(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.size < RELATED_LIMIT) {
|
||||
localCache.notes.forEach { _, note ->
|
||||
if (note.idHex != noteId &&
|
||||
note.event is TextNoteEvent &&
|
||||
note.event?.pubKey == authorPubKey &&
|
||||
note !in results
|
||||
) {
|
||||
results.add(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sortedByDescending { it.createdAt() }
|
||||
.take(RELATED_LIMIT)
|
||||
.map { note ->
|
||||
val event = note.event
|
||||
val content = event?.content ?: ""
|
||||
val firstLine =
|
||||
content
|
||||
.take(80)
|
||||
.lineSequence()
|
||||
.firstOrNull()
|
||||
?.take(60) ?: ""
|
||||
val author = localCache.getUserIfExists(event?.pubKey ?: "")
|
||||
val imageUrl =
|
||||
UrlParser()
|
||||
.parseValidUrls(content)
|
||||
.withScheme
|
||||
.firstOrNull { RichTextParser.isImageUrl(it) }
|
||||
CompactNoteData(
|
||||
id = note.idHex,
|
||||
title = firstLine.ifBlank { "Note" },
|
||||
authorName = author?.toBestDisplayName() ?: event?.pubKey?.take(8) ?: "",
|
||||
thumbnailUrl = imageUrl,
|
||||
zapCount = if (note.zapsAmount > BigDecimal.ZERO) showAmount(note.zapsAmount) else "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactRelatedCard(
|
||||
item: CompactNoteData,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.width(200.dp)
|
||||
.height(140.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = shape,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Background: image or gradient placeholder
|
||||
if (item.thumbnailUrl != null) {
|
||||
AsyncImage(
|
||||
model = item.thumbnailUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clip(shape),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Dark gradient overlay at bottom
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Text over the gradient
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
val subtitle =
|
||||
buildString {
|
||||
append(item.authorName)
|
||||
if (item.zapCount.isNotBlank()) {
|
||||
append(" · ")
|
||||
append(item.zapCount)
|
||||
append(" zaps")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
---
|
||||
title: Fix 5 review findings on PR #3124 desktop feed UI refresh
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-06-02
|
||||
pr: https://github.com/vitorpamplona/amethyst/pull/3124
|
||||
review_comment: https://github.com/vitorpamplona/amethyst/pull/3124#issuecomment-4599816576
|
||||
worktree: ../AmethystMultiplatform-feed-review
|
||||
branch: fix/desktop-feed-ui-review (tracks origin/feat/desktop-feed-ui-refresh)
|
||||
deepened: 2026-06-02
|
||||
---
|
||||
|
||||
# Fix 5 review findings on PR #3124
|
||||
|
||||
## Enhancement summary (2026-06-02 deepen-plan)
|
||||
|
||||
Eight parallel agents resolved all 5 open questions and surfaced 3 plan revisions:
|
||||
|
||||
**Open questions resolved:**
|
||||
- **Q1 — kind-1 NIP-10 vs kind-1111 NIP-22:** kind-1 NIP-10 confirmed. Android's
|
||||
`NotificationReplyReceiver` already routes by parent type
|
||||
(`TextNoteEvent` → kind 1, others → kind 1111). Desktop feed loads kind 1
|
||||
only (`DesktopFeedFilters.kt:39`). Use kind 1 + `prepareETagsAsReplyTo`.
|
||||
- **Q2 — extract consume+broadcast couplet:** YES. Five clean call sites (no
|
||||
inline complexity) + ordering inconsistency (`TextNoteEvent` does
|
||||
consume→broadcast at `ThreadScreen.kt:361` and `FeedScreen.kt:1564`,
|
||||
Reaction/Follow do broadcast→consume at `ThreadScreen.kt:424`,
|
||||
`FeedScreen.kt:426`/`:1617`). A `desktopApp` extension fixes both volume and
|
||||
the ordering drift. Canonical order: consume→broadcast (local-first).
|
||||
- **Q3 — Phase 4 produceState vs ViewModel:** produceState. `LargeCache.notes`
|
||||
is a `ConcurrentSkipListMap` (`LargeCache.jvmAndroid.kt:27`) — weakly
|
||||
consistent iterator, safe on main composition coroutine, 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<TextNoteEvent>? = null,
|
||||
forkingFrom: EventHintBundle<TextNoteEvent>? = null,
|
||||
…
|
||||
) = eventTemplate(KIND, note, createdAt) {
|
||||
alt(shortedMessageForAlt(note))
|
||||
if (replyingTo != null || forkingFrom != null) {
|
||||
markedETags(prepareETagsAsReplyTo(replyingTo, forkingFrom))
|
||||
}
|
||||
initializer()
|
||||
}
|
||||
```
|
||||
|
||||
`prepareETagsAsReplyTo` (already in quartz) handles **root marker, reply marker,
|
||||
parent root/p-tag carry** correctly. The inline path was bypassing it.
|
||||
|
||||
### #4 — Reaction/follow/reply business logic in desktop composables
|
||||
|
||||
**Files:**
|
||||
- `FeedScreen.kt:1611` — reaction (likes from inline expansion)
|
||||
- `FeedScreen.kt:423` — follow (follow pill from feed)
|
||||
- `FeedScreen.kt:1554` — reply (inline reply input)
|
||||
|
||||
**Current state (verified):**
|
||||
- **Follow** already uses `FollowAction.follow(pubKeyHex, signer, currentList)`
|
||||
(commons). The complaint is the surrounding `cache.consume → broadcast`
|
||||
couplet inlined in the composable.
|
||||
- **Reaction** already uses `ReactionAction.reactTo(EventHintBundle, "+", signer)`
|
||||
(commons). Same couplet inlined.
|
||||
- **Reply** does NOT use a shared builder (see #3).
|
||||
|
||||
**CLAUDE.md rule (`commons/ARCHITECTURE.md:73-88`):** "actions package (CLI-safe):
|
||||
Event builders for user actions (follow, zap…). The canonical entry point for
|
||||
non-UI callers."
|
||||
|
||||
**Fix:** introduce a new shared action that mirrors `FollowActions` for kind-1
|
||||
replies. The consume+broadcast couplet stays inline (2 lines, platform-specific
|
||||
relay/cache wiring), but the *protocol-touching* build moves out:
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/nip10Notes/ReplyActions.kt
|
||||
object ReplyActions {
|
||||
suspend fun replyTo(
|
||||
parent: EventHintBundle<TextNoteEvent>,
|
||||
content: String,
|
||||
signer: NostrSigner,
|
||||
): TextNoteEvent {
|
||||
val template = TextNoteEvent.build(content, replyingTo = parent)
|
||||
return signer.sign(template)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Desktop call site becomes:
|
||||
```kotlin
|
||||
val parentText = event as? TextNoteEvent ?: return@withContext
|
||||
val signed = ReplyActions.replyTo(EventHintBundle(parentText, null), content, account.signer)
|
||||
localCache.consume(signed, relay = null)
|
||||
relayManager.broadcastToAll(signed)
|
||||
```
|
||||
|
||||
This matches the shape already used for `FollowAction.follow` at `FeedScreen.kt:423`
|
||||
and `ReactionAction.reactTo` at `NoteActions.kt:1393`. Drift between desktop and
|
||||
Android paths is bounded to a 3-line couplet that won't grow.
|
||||
|
||||
### #1 — Related content stale after one scan
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt:83`
|
||||
|
||||
**Current code:**
|
||||
```kotlin
|
||||
DisposableEffect(noteId) {
|
||||
val results = mutableListOf<Note>()
|
||||
// scan localCache.notes once
|
||||
localCache.notes.forEach { … }
|
||||
relatedItems = results.sortedByDescending { it.createdAt() }.take(6).map { … }
|
||||
onDispose { }
|
||||
}
|
||||
```
|
||||
|
||||
**Root cause:** `DisposableEffect(noteId)` re-runs only on `noteId` change. The
|
||||
scan reads `localCache.notes` (a `LargeCache`) at composition time; nothing
|
||||
re-runs the scan as the cache fills. Expanding a note on a cold cache leaves
|
||||
the section empty/partial until collapse+re-expand. Also missing keys:
|
||||
`noteHashtags` and `authorPubKey` (cosmetic — caller stabilises these per noteId).
|
||||
|
||||
**Fix:** observe the cache's change stream and re-scan on bundle arrivals.
|
||||
`DesktopLocalCache` exposes `eventStream: DesktopCacheEventStream` with
|
||||
`newEventBundles: SharedFlow<Set<Note>>` (`DesktopLocalCache.kt:719-743`).
|
||||
|
||||
Two options:
|
||||
|
||||
**Option A (simpler, matches inline-section scale):** `produceState` keyed by
|
||||
`(noteId, hashtagsHash, authorPubKey)` that collects `newEventBundles` and
|
||||
re-runs the scan when relevant events land:
|
||||
|
||||
```kotlin
|
||||
val relatedItems by produceState<List<CompactNoteData>>(emptyList(), noteId, authorPubKey, noteHashtags) {
|
||||
fun rescan() { value = scanRelated(localCache, noteId, authorPubKey, noteHashtags) }
|
||||
rescan() // initial
|
||||
localCache.eventStream.newEventBundles.collect { bundle ->
|
||||
if (bundle.any { isCandidate(it, noteHashtags, authorPubKey) }) rescan()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option B (matches FeedViewModel family):** new `RelatedContentViewModel` in
|
||||
`commons/src/commonMain/.../viewmodels/related/`, taking a `FeedFilter` style
|
||||
"by hashtag OR by author" predicate and exposing
|
||||
`StateFlow<List<CompactNoteData>>`. Heavier but consistent with the rest of the
|
||||
feed system.
|
||||
|
||||
**Recommendation:** **Option A** — the related-content row is a 6-item sidecar,
|
||||
not a feed. A ViewModel adds wiring without solving an actual problem here.
|
||||
Deepen-plan agent may overrule.
|
||||
|
||||
### #2 — Deck columns steal focus from each other
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:150`
|
||||
|
||||
**Current code:**
|
||||
```kotlin
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(currentOverlay) { focusRequester.requestFocus() }
|
||||
```
|
||||
|
||||
**Root cause:** `LaunchedEffect(currentOverlay)` fires on the column's first
|
||||
composition **and on every overlay change**. In a multi-column deck, when
|
||||
column B opens an overlay, column B's effect grabs focus — yanking it out of
|
||||
column A's inline reply input mid-typing.
|
||||
|
||||
**Fix:** decouple "request focus once on initial composition" from "Escape
|
||||
handler needs focus to be live." The Escape key path works as long as the
|
||||
column owns focus when the user presses Escape — which it does after the
|
||||
initial composition. Match the existing pattern at
|
||||
`EditProfileScreen.kt:380` (`LaunchedEffect(Unit) { focusRequester.requestFocus() }`)
|
||||
**and** scope the effect so only the column the user is interacting with
|
||||
re-grabs focus when a nested overlay closes (i.e. on `popOverlay()`).
|
||||
|
||||
Concretely:
|
||||
1. Change `LaunchedEffect(currentOverlay)` → `LaunchedEffect(Unit)` for the
|
||||
initial focus request.
|
||||
2. When the user presses Escape and `navState.pop()` succeeds, explicitly call
|
||||
`focusRequester.requestFocus()` in the key handler (intent-driven, not
|
||||
composition-driven).
|
||||
|
||||
This contains focus stealing to the column the user actually interacted with.
|
||||
|
||||
### #5 — Zap totals bypass shared formatter
|
||||
|
||||
**Files:**
|
||||
- `RelatedContentRow.kt:137` — `zapCount = "${note.zapsAmount.toLong()}"` (raw, e.g. `"1500000"`)
|
||||
- `CommentItem.kt:155` — `text = formatZapAmount(zapAmount)` calling local helper
|
||||
- `CommentItem.kt:166-171` — private `formatZapAmount(sats: Long)` hand-rolled k/M
|
||||
|
||||
**Shared formatter (`commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt`):**
|
||||
- `fun showAmount(amount: BigDecimal?): String` — G/M/k suffixes, `""` for null/<0.01
|
||||
- `fun showAmountWithZero(amount: BigDecimal?): String` — same, `"0"` instead of `""`
|
||||
- `fun Long.toZapAmount(): String`
|
||||
- `fun Int.toZapAmount(): String`
|
||||
|
||||
`Note.zapsAmount` is `BigDecimal` (`commons/src/commonMain/.../model/Note.kt:183`),
|
||||
so use `showAmount(note.zapsAmount)` directly in `RelatedContentRow`. `CommentItem`
|
||||
takes a `Long`, so use `zapAmount.toZapAmount()` and delete the local helper.
|
||||
|
||||
**Also flagged (outside review but same root cause):**
|
||||
- `NoteActions.kt:264` — local `formatSats(amount: Long)` with only `k` suffix.
|
||||
- `WalletColumnScreen.kt:979` — local `formatSats` using `NumberFormat` (intentional?
|
||||
wallet flows may want full sats — leave but document).
|
||||
|
||||
Cover the two review-flagged sites + `NoteActions.kt:264`. Defer wallet.
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
Phases ordered so each compiles + tests cleanly without depending on later work.
|
||||
|
||||
### Phase 1 — Shared `ReplyActions` (fixes #3, completes #4 reply)
|
||||
|
||||
**Create:** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt` *(flat — matches `FollowActions.kt`, `ZapActions.kt`, `DmActions.kt`; no `nip10Notes/` subpackage)*
|
||||
|
||||
```kotlin
|
||||
object ReplyActions {
|
||||
suspend fun replyTo(
|
||||
parent: EventHintBundle<TextNoteEvent>,
|
||||
content: String,
|
||||
signer: NostrSigner,
|
||||
): TextNoteEvent {
|
||||
val template = TextNoteEvent.build(content, replyingTo = parent)
|
||||
return signer.sign(template)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mirror `FollowActions.buildFollow` shape (`commons/.../actions/FollowActions.kt:69`).
|
||||
|
||||
**Test:** `commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt`
|
||||
— assert the signed event has:
|
||||
- A marked e-tag with `marker=reply` pointing at the parent id.
|
||||
- A marked e-tag with `marker=root` (pointing at parent's root if parent had one,
|
||||
else parent's id).
|
||||
- All parent p-tags carried + parent's `pubKey` appended.
|
||||
|
||||
Use `runTest { … }` from `kotlinx-coroutines-test`, in-test signer is
|
||||
`NostrSignerInternal(KeyPair(privHex.hexToByteArray()))` — match `FollowActionsTest.kt:25-37`.
|
||||
|
||||
**Edit:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt` (~ line 1554) — replace inline build with `ReplyActions.replyTo(EventHintBundle(parentText, null), content, account.signer)`. Guard parent kind with `event as? TextNoteEvent`; if not a kind-1, skip / log (replies to non-kind-1 from the feed inline path were never well-defined and are out of scope; matches Android's `NotificationReplyReceiver.kt:136-156` routing).
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] `./gradlew :commons:jvmTest --tests "*ReplyActionsTest*"` green.
|
||||
- [ ] Manual: reply to a deep-thread note from desktop, inspect the broadcast event in a relay log → has `e-tag root` + `e-tag reply` + carries all parent `p` tags.
|
||||
- [ ] Reply renders in Damus/Primal under the correct thread.
|
||||
|
||||
### Phase 2 — Extract reaction/follow/reply consume+broadcast couplet (Option B confirmed)
|
||||
|
||||
Deepen-plan audit found **5 clean duplicate sites** with an ordering
|
||||
inconsistency between them:
|
||||
|
||||
| File:line | Signer | Order today |
|
||||
|---|---|---|
|
||||
| `ThreadScreen.kt:361` | inline `TextNoteEvent.build` reply | consume → broadcast |
|
||||
| `ThreadScreen.kt:424` | `ReactionAction.reactTo` | broadcast → consume |
|
||||
| `FeedScreen.kt:426` | `FollowAction.follow` | broadcast → consume |
|
||||
| `FeedScreen.kt:1564` | inline `TextNoteEvent.build` reply | consume → broadcast |
|
||||
| `FeedScreen.kt:1617` | `ReactionAction.reactTo` | broadcast → consume |
|
||||
|
||||
No site has inline extra work (snackbars, retries) entangled with the couplet.
|
||||
|
||||
**Create:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt`
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Canonical local-first dispatch: write to the local cache before broadcasting
|
||||
* so the UI reflects the user's action immediately, even if relay round-trips fail.
|
||||
*/
|
||||
suspend fun dispatch(
|
||||
signed: Event,
|
||||
localCache: DesktopLocalCache,
|
||||
relayManager: RelayManager,
|
||||
) {
|
||||
localCache.consume(signed, relay = null)
|
||||
relayManager.broadcastToAll(signed)
|
||||
}
|
||||
```
|
||||
|
||||
(Or, equivalent — as an extension on a small `DispatchContext` if the call
|
||||
sites already have one. Keep in `desktopApp` because both `LocalCache` and
|
||||
`RelayManager` are desktop-side types.)
|
||||
|
||||
**Migrate all 5 sites** to call `dispatch(signed, localCache, relayManager)`.
|
||||
Fixes the ordering drift (everyone goes local-first) and shrinks call sites
|
||||
to one line.
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] `grep -rn "broadcastToAll" desktopApp/` shows only the call inside `EventDispatch.kt` + any non-couplet uses.
|
||||
- [ ] No remaining call sites do consume + broadcast inline (other than the helper).
|
||||
- [ ] Reactions, follows, and replies all still round-trip correctly in a manual sanity test.
|
||||
|
||||
### Phase 3 — ZapFormatter swap (fixes #5)
|
||||
|
||||
**Edit:** `RelatedContentRow.kt:137` —
|
||||
```kotlin
|
||||
zapCount = if (note.zapsAmount > BigDecimal.ZERO) showAmount(note.zapsAmount) else "",
|
||||
```
|
||||
|
||||
**Edit:** `CommentItem.kt:155, 166-171` — replace `formatZapAmount(zapAmount)` with
|
||||
`zapAmount.toZapAmount()`. **Delete the private `formatZapAmount` fun at line 166-171.**
|
||||
|
||||
**Edit:** `NoteActions.kt:264` — swap to `amount.toZapAmount()`. Verified
|
||||
sats (not msats): inputs are `ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)`
|
||||
at line 111 + `LnZapEvent.amount` which is sats per `LnZapEvent.kt:69`.
|
||||
**Delete the private `formatSats` fun if no remaining references** (run
|
||||
`grep -n formatSats desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt` after swap).
|
||||
|
||||
**Skip:** `WalletColumnScreen.kt:979` `formatSats` is **intentional** — wallet
|
||||
balance display uses locale-aware grouping (`NumberFormat.getNumberInstance().format`)
|
||||
for full-precision sats. Out of review scope. Add a one-line `// intentional: wallet shows precise sats with locale grouping; not a ZapFormatter target` comment to prevent future drift.
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` green.
|
||||
- [ ] Visual: a note with 1.5M sats renders `1.5M` (or `1M`, matching commons
|
||||
semantics), not `1500000`.
|
||||
|
||||
### Phase 4 — Cache-aware Related (fixes #1)
|
||||
|
||||
**Edit:** `RelatedContentRow.kt:83-145` — replace `DisposableEffect(noteId)` with
|
||||
`produceState` (Option A) keyed on `(noteId, authorPubKey, noteHashtags)`:
|
||||
|
||||
```kotlin
|
||||
val relatedItems by produceState<List<CompactNoteData>>(
|
||||
initialValue = emptyList(),
|
||||
key1 = noteId, key2 = authorPubKey, key3 = noteHashtags,
|
||||
) {
|
||||
val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet()
|
||||
fun rescan() {
|
||||
runCatching {
|
||||
value = scanRelated(localCache, noteId, authorPubKey, lowercaseTags)
|
||||
}.onFailure {
|
||||
// weakly-consistent iterator may rarely surface; skip this tick
|
||||
}
|
||||
}
|
||||
rescan()
|
||||
localCache.eventStream.newEventBundles.collect { bundle ->
|
||||
val matters = bundle.any { n ->
|
||||
n.event is TextNoteEvent &&
|
||||
n.idHex != noteId &&
|
||||
(n.event?.pubKey == authorPubKey ||
|
||||
n.event?.tags?.isTaggedHashes(lowercaseTags) == true)
|
||||
}
|
||||
if (matters) rescan()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Extract the existing scan body into a private top-level `scanRelated(...)` so
|
||||
both the initial call and the bundle-driven re-run share it.
|
||||
|
||||
**Safety notes (deepen-plan):**
|
||||
- `LargeCache.notes` is `ConcurrentSkipListMap` (`LargeCache.jvmAndroid.kt:27`)
|
||||
— weakly-consistent iterator, safe on main composition coroutine.
|
||||
- Scan is O(N): ~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<TextNoteEvent>` cast fails when parent is `CommentEvent` / `LongFormContentEvent` | Med | Guard with `as? TextNoteEvent`; skip + log if null. Open question covers full support. |
|
||||
| `produceState` re-runs scan storm on cold cache fill | Low | Filter bundle for candidate match before rescan; debounce if observed. |
|
||||
| `LargeCache.forEach` concurrent modification during rescan | Low | Wrap rescan body in `runCatching`. |
|
||||
| Focus fix breaks ESC → back-nav inside a column | Low | Explicit `requestFocus()` in pop handler covers it; manual repro before push. |
|
||||
| `ZapFormatter.showAmount` returns `""` for amount < 0.01 — different from current `"0"`-on-empty | Low | Use `showAmountWithZero` if `"0"` desired, else gate with `if (note.zapsAmount > ZERO)`. |
|
||||
|
||||
## Resolved questions (deepen-plan)
|
||||
|
||||
All 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.
|
||||
Reference in New Issue
Block a user