From 144b911867938d1788cbc706b6cd4df74e6b5690 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 10:17:08 +0300 Subject: [PATCH 01/17] feat(desktop): move actions inside card + fix sidebar double active state - Add bottomContent slot to NoteCard for actions to render inside card boundary - Move NoteActionsRow into the slot in FeedNoteCard (both regular and repost paths) - Add muted parameter to SidebarNavItem; mute Home when feed tabs are visible - Resolves feedback: actions clearly belong to their card, sidebar doesn't compete with feed tab active state Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/ui/FeedScreen.kt | 125 +++++++++--------- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 23 ++-- .../amethyst/desktop/ui/note/NoteCard.kt | 5 + 4 files changed, 85 insertions(+), 69 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index d1548c194e..eb6b9f7634 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1459,6 +1459,7 @@ fun MainContent( } }, activeColumnType = activeColumnType, + feedTabActive = activeColumnType is DeckColumnType.HomeFeed, onShowImportFollowListDialog = onShowImportFollowListDialog, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 046cb72d75..996a01f813 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -226,7 +226,7 @@ fun FeedNoteCard( BoostedMark() } - // Original note content + // Original note content with actions inside card val displayData = remember(originalEvent, metadataState) { originalEvent.toNoteDisplayData(localCache) } NoteCard( note = displayData, @@ -238,30 +238,33 @@ fun FeedNoteCard( onHashtagClick = onHashtagClick, onImageClick = onImageClick, onMediaClick = onMediaClick, + 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 +283,44 @@ 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) } + NoteCard( + note = displayData, + modifier = Modifier.fillMaxWidth(), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onHashtagClick = onHashtagClick, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + 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 + }, + ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 34554d5e18..623819af24 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -113,6 +113,7 @@ fun MainSidebar( onOpenSettings: () -> Unit, onNavigate: (DeckColumnType) -> Unit, activeColumnType: DeckColumnType?, + feedTabActive: Boolean = false, onShowImportFollowListDialog: () -> Unit = {}, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, @@ -183,12 +184,14 @@ fun MainSidebar( ) { NAV_ITEMS.forEach { item -> val isActive = activeColumnType?.typeKey() == item.type.typeKey() + val isMuted = isActive && item.type is DeckColumnType.HomeFeed && feedTabActive SidebarNavItem( icon = item.icon, label = item.label, isActive = isActive, expanded = expanded, onClick = { onNavigate(item.type) }, + muted = isMuted, ) } @@ -439,28 +442,30 @@ private fun SidebarNavItem( isActive: Boolean, expanded: Boolean, onClick: () -> Unit, + muted: Boolean = false, ) { var isHovered by remember { mutableStateOf(false) } val backgroundColor = when { + isActive && muted -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) isActive -> MaterialTheme.colorScheme.primaryContainer isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) else -> MaterialTheme.colorScheme.surface } val iconTint = - if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant + when { + isActive && muted -> MaterialTheme.colorScheme.onSurfaceVariant + isActive -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant } val textColor = - if (isActive) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant + when { + isActive && muted -> MaterialTheme.colorScheme.onSurfaceVariant + isActive -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant } Row( @@ -491,7 +496,7 @@ private fun SidebarNavItem( Text( text = label, style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + fontWeight = if (isActive && !muted) FontWeight.SemiBold else FontWeight.Normal, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 3529fbed08..d06f869eed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -101,6 +101,7 @@ fun NoteCard( onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, onPayInvoice: ((String) -> Unit)? = null, + bottomContent: (@Composable ColumnScope.() -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = @@ -306,6 +307,10 @@ fun NoteCard( } } } + + if (bottomContent != null) { + bottomContent() + } } if (onClick != null) { From fd6e37b84a5a17405152a4e1ef804d33bffd0bd1 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 10:23:24 +0300 Subject: [PATCH 02/17] feat(desktop): slide-animated inline navigation with 2-level back stack cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor ColumnNavigationState to use mutableStateListOf with direction tracking - Add pushWithCap(maxDepth=2) — replaces top entry when cap reached - Replace instant Surface overlay with AnimatedContent slide transitions (200ms) - Add Esc key handler (onPreviewKeyEvent) for back navigation - Add FocusRequester for keyboard nav to work after slide - Apply to both DeckColumnContainer and SinglePaneLayout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/ui/deck/DeckColumnContainer.kt | 166 ++++++++++++++---- .../desktop/ui/deck/SinglePaneLayout.kt | 69 +++++--- 2 files changed, 174 insertions(+), 61 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 86d23cf80f..4a203c74b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -20,6 +20,14 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight @@ -29,11 +37,22 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen @@ -65,25 +84,38 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow class ColumnNavigationState { - private val _stack = MutableStateFlow>(emptyList()) - val stack: kotlinx.coroutines.flow.StateFlow> = _stack.asStateFlow() + private val _stack = mutableStateListOf() + val stack: List get() = _stack + val current: DesktopScreen? get() = _stack.lastOrNull() + val hasBackStack: Boolean get() = _stack.isNotEmpty() + + var navigatingForward by mutableStateOf(true) + private set + + fun pushWithCap( + screen: DesktopScreen, + maxDepth: Int = 2, + ) { + navigatingForward = true + if (_stack.size >= maxDepth) _stack.removeFirst() + _stack.add(screen) + } fun push(screen: DesktopScreen) { - _stack.value = _stack.value + screen + pushWithCap(screen) } fun pop(): Boolean { - if (_stack.value.isEmpty()) return false - _stack.value = _stack.value.dropLast(1) + if (_stack.isEmpty()) return false + navigatingForward = false + _stack.removeLast() return true } fun clear() { - _stack.value = emptyList() + _stack.clear() } } @@ -111,19 +143,34 @@ 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 on nav change so Esc key works + LaunchedEffect(currentOverlay) { + 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 +194,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 +219,75 @@ fun DeckColumnContainer( onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, onNavigateToRelays = onNavigateToRelays, ) - if (currentOverlay != null) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.fillMaxSize(), - ) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, - onBack = { navState.pop() }, - ) + + // Overlay with slide animation + AnimatedContent( + targetState = currentOverlay, + transitionSpec = { + val duration = 200 + if (navState.navigatingForward) { + ( + slideInHorizontally( + tween(duration), + ) { it } + + fadeIn( + androidx.compose.animation.core + .tween(duration), + ) + ).togetherWith( + slideOutHorizontally( + tween(duration), + ) { -it } + + fadeOut( + androidx.compose.animation.core + .tween(duration), + ), + ) + } else { + ( + slideInHorizontally( + tween(duration), + ) { -it } + + fadeIn( + androidx.compose.animation.core + .tween(duration), + ) + ).togetherWith( + slideOutHorizontally( + tween(duration), + ) { it } + + fadeOut( + androidx.compose.animation.core + .tween(duration), + ), + ) + } + }, + label = "ColumnNavAnimation", + ) { overlayScreen -> + if (overlayScreen != null) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = overlayScreen, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onBack = { navState.pop() }, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 922f69f934..29e7779a3b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -20,6 +20,13 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -74,8 +81,7 @@ fun SinglePaneLayout( ) { val currentColumnType by singlePaneState.currentScreen.collectAsState() val navState = remember { ColumnNavigationState() } - val navStack by navState.stack.collectAsState() - val currentOverlay = navStack.lastOrNull() + val currentOverlay = navState.current // Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes). // SinglePaneLayout only renders the content pane. @@ -124,28 +130,43 @@ fun SinglePaneLayout( onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) }, onOpenFeedsDrawer = onOpenFeedsDrawer, ) - if (currentOverlay != null) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.fillMaxSize(), - ) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, - onBack = { navState.pop() }, - ) + AnimatedContent( + targetState = currentOverlay, + transitionSpec = { + val duration = 200 + if (navState.navigatingForward) { + (slideInHorizontally(tween(duration)) { it } + fadeIn(tween(duration))) + .togetherWith(slideOutHorizontally(tween(duration)) { -it } + fadeOut(tween(duration))) + } else { + (slideInHorizontally(tween(duration)) { -it } + fadeIn(tween(duration))) + .togetherWith(slideOutHorizontally(tween(duration)) { it } + fadeOut(tween(duration))) + } + }, + label = "SinglePaneNavAnimation", + ) { overlayScreen -> + if (overlayScreen != null) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = overlayScreen, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onBack = { navState.pop() }, + ) + } } } } From 1294283937944d3a751648b6d7ebd2b24784fc7f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 10:31:53 +0300 Subject: [PATCH 03/17] feat(desktop): follow pill in feed card header - Add headerTrailingContent slot to NoteCard for follow pill placement - Add FollowPill composable (FilterChip with PersonAdd icon) - Wire follow action in FeedScreen: FollowAction.follow + broadcastToAll - Mutex guards concurrent follows to prevent kind:3 overwrites - Expose lastContactListEvent on DesktopLocalCache for follow operations - Hidden for own notes, already-followed users, and logged-out users Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/cache/DesktopLocalCache.kt | 4 + .../amethyst/desktop/ui/FeedScreen.kt | 77 +++++++++++++++++++ .../amethyst/desktop/ui/note/NoteCard.kt | 5 ++ 3 files changed, 86 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index d0feee80c2..be1a200191 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -441,10 +441,14 @@ class DesktopLocalCache : ICacheProvider { */ private var lastContactListCreatedAt = 0L + var lastContactListEvent: ContactListEvent? = null + private set + private fun consumeContactList(event: ContactListEvent): Boolean { // Replaceable event — only accept newer contact lists if (event.createdAt <= lastContactListCreatedAt) return false lastContactListCreatedAt = event.createdAt + lastContactListEvent = event _followedUsers.value = event.verifiedFollowKeySet() return true } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 996a01f813..dbf3d7552e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -85,6 +85,7 @@ 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.model.Note +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState @@ -137,6 +138,8 @@ 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 data class LightboxState( val urls: List, @@ -165,6 +168,9 @@ fun FeedNoteCard( onImageClick: ((List, Int) -> Unit)? = null, onMediaClick: ((List, Int, Float) -> Unit)? = null, onHashtagClick: ((String) -> Unit)? = null, + followedUsers: Set = emptySet(), + myPubKeyHex: String? = null, + onFollow: ((String) -> Unit)? = null, ) { val event = note.event ?: return val isRepost = event is RepostEvent || event is GenericRepostEvent @@ -228,6 +234,11 @@ fun FeedNoteCard( // 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,6 +249,14 @@ fun FeedNoteCard( onHashtagClick = onHashtagClick, onImageClick = onImageClick, onMediaClick = onMediaClick, + headerTrailingContent = + if (showRepostFollowPill) { + { + FollowPill(onClick = { onFollow.invoke(originalEvent.pubKey) }) + } + } else { + null + }, bottomContent = if (account != null) { { @@ -284,6 +303,11 @@ fun FeedNoteCard( } 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(), @@ -294,6 +318,14 @@ fun FeedNoteCard( onHashtagClick = onHashtagClick, onImageClick = onImageClick, onMediaClick = onMediaClick, + headerTrailingContent = + if (showFollowPill) { + { + FollowPill(onClick = { onFollow.invoke(event.pubKey) }) + } + } else { + null + }, bottomContent = if (account != null) { { @@ -361,6 +393,23 @@ fun FeedScreen( var replyToEvent by remember { mutableStateOf(null) } var lightboxState by remember { mutableStateOf(null) } + + // Follow pill state + val scope = rememberCoroutineScope() + val followMutex = remember { Mutex() } + val onFollowFromFeed: (String) -> Unit = { pubKeyHex -> + if (account != null) { + scope.launch(Dispatchers.IO) { + followMutex.withLock { + val currentList = localCache.lastContactListEvent + val updatedEvent = FollowAction.follow(pubKeyHex, account.signer, currentList) + relayManager.broadcastToAll(updatedEvent) + // consume updates followedUsers StateFlow + stores the event + localCache.consume(updatedEvent, relay = null) + } + } + } + } var showRelayPicker by remember { mutableStateOf(false) } var activeFeedId by remember { mutableStateOf(customFeedId) } var activeFeedSource by remember { @@ -710,6 +759,9 @@ fun FeedScreen( com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer .toggleFullscreen() }, + followedUsers = followedUsers, + myPubKeyHex = account?.pubKeyHex, + onFollow = onFollowFromFeed, ) } } @@ -1381,3 +1433,28 @@ private fun FeedHeader( } } } + +@Composable +private fun FollowPill( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FilterChip( + selected = false, + onClick = onClick, + label = { + Text( + "Follow", + style = MaterialTheme.typography.labelSmall, + ) + }, + leadingIcon = { + Icon( + MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + }, + modifier = modifier.height(28.dp), + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index d06f869eed..77344c53ba 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -102,6 +102,7 @@ fun NoteCard( onMediaClick: ((List, Int, Float) -> Unit)? = null, onPayInvoice: ((String) -> Unit)? = null, bottomContent: (@Composable ColumnScope.() -> Unit)? = null, + headerTrailingContent: (@Composable () -> Unit)? = null, ) { val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = @@ -195,6 +196,10 @@ fun NoteCard( ) } + if (headerTrailingContent != null) { + headerTrailingContent() + } + // Timestamp ToggleableTimeAgoText( timestamp = note.createdAt, From cc1330adb6be205735688396303ce46cc48baa1e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 11:06:50 +0300 Subject: [PATCH 04/17] feat(desktop): inline reply in thread view - Create InlineReplyInput composable (avatar + TextField + Send button) - SendState sealed interface (Idle/Sending/Error) - Ctrl/Cmd+Enter keyboard shortcut to send - Build kind:1 reply with NIP-10 e-tag + p-tag - Optimistic display via localCache.consume + broadcastToAll - Error shown inline with text preserved for retry - Hidden for logged-out users Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/ThreadScreen.kt | 61 ++++++ .../desktop/ui/thread/InlineReplyInput.kt | 181 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index c1878269a0..1d920ded56 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -70,11 +70,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.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.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +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.withContext /** * Desktop Thread Screen - displays a note and all its replies in a thread view. @@ -311,6 +321,37 @@ fun ThreadScreen( } } + // Inline reply input + if (account != null && rootNote != null) { + item(key = "inline-reply") { + 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 rootEvent = rootNote.event ?: return@withContext + val template = + TextNoteEvent.build(content) { + val etag = ETag(rootEvent.id) + etag.relay = null + etag.author = rootEvent.pubKey + eTag(etag) + pTag(PTag(rootEvent.pubKey, relayHint = null)) + } + val signedEvent = account.signer.sign(template) + localCache.consume(signedEvent, relay = null) + relayManager.broadcastToAll(signedEvent) + } + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + HorizontalDivider(thickness = 1.dp) + } + } + // Reply notes with level indicators items(replyNotes, key = { it.idHex }) { note -> val level = calculateLevel(note) @@ -361,6 +402,26 @@ fun ThreadScreen( ) } } + + // 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, + ) + } + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt new file mode 100644 index 0000000000..3f4ef3fe76 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt @@ -0,0 +1,181 @@ +/* + * 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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo +import kotlinx.coroutines.launch + +sealed interface SendState { + data object Idle : SendState + + data object Sending : SendState + + data class Error( + val message: String, + ) : SendState +} + +@Composable +fun InlineReplyInput( + myAvatarUrl: String?, + onSend: suspend (String) -> Unit, + modifier: Modifier = Modifier, +) { + var text by remember { mutableStateOf("") } + var sendState by remember { mutableStateOf(SendState.Idle) } + val scope = rememberCoroutineScope() + + val isSending = sendState is SendState.Sending + + fun doSend() { + val content = text.trim() + if (content.isEmpty() || isSending) return + sendState = SendState.Sending + scope.launch { + try { + onSend(content) + text = "" + sendState = SendState.Idle + } catch (e: Exception) { + sendState = SendState.Error(e.message ?: "Failed to send reply") + } + } + } + + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + UserAvatar( + userHex = "", + pictureUrl = myAvatarUrl, + size = 32.dp, + ) + Spacer(Modifier.width(8.dp)) + OutlinedTextField( + value = text, + onValueChange = { + text = it + // Clear error on new input + if (sendState is SendState.Error) sendState = SendState.Idle + }, + placeholder = { + Text( + "Add a comment...", + style = MaterialTheme.typography.bodyMedium, + ) + }, + modifier = + Modifier + .weight(1f) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.Enter) { + val modifierHeld = + if (PlatformInfo.isMacOS) { + keyEvent.isMetaPressed + } else { + keyEvent.isCtrlPressed + } + if (modifierHeld) { + doSend() + true + } else { + false + } + } else { + false + } + }, + textStyle = MaterialTheme.typography.bodyMedium, + singleLine = false, + maxLines = 5, + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { doSend() }, + enabled = text.isNotBlank() && !isSending, + modifier = Modifier.size(36.dp), + ) { + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + MaterialSymbols.AutoMirrored.Send, + contentDescription = "Send reply", + modifier = Modifier.size(20.dp), + tint = + if (text.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + // 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), + ) + } + } +} From 25c9cf46116fe116c517e507f9b1c1e585ad13fe Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 11:07:19 +0300 Subject: [PATCH 05/17] feat(desktop): share menu with copy/broadcast options - Create ShareMenu composable with ShareMenuState - 6 share options: Copy Text, Copy Note ID, Copy Event Link, Copy Raw JSON, Copy Web Link (njump.me), Broadcast - Replace MoreVert overflow menu with Share icon + ShareMenu - Use existing copyToClipboard helper for clipboard operations Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/NoteActions.kt | 65 ++-------- .../amethyst/desktop/ui/note/ShareMenu.kt | 116 ++++++++++++++++++ 2 files changed, 128 insertions(+), 53 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index fd31ba84b6..367dcad3a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -91,14 +91,14 @@ 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 +106,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) @@ -1312,56 +1310,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, + ) } } @@ -1693,11 +1660,3 @@ private suspend fun fetchUserLightningAddress( relayManager.unsubscribe(subId) } } - -/** - * Copies text to the system clipboard. - */ -private fun copyToClipboard(text: String) { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(text), null) -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt new file mode 100644 index 0000000000..7287250cba --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.note + +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +class ShareMenuState { + var expanded by mutableStateOf(false) + private set + + fun open() { + expanded = true + } + + fun dismiss() { + expanded = false + } +} + +@Composable +fun rememberShareMenuState(): ShareMenuState = remember { ShareMenuState() } + +@Composable +fun ShareMenu( + state: ShareMenuState, + event: Event, + relayManager: DesktopRelayConnectionManager, +) { + DropdownMenu( + expanded = state.expanded, + onDismissRequest = { state.dismiss() }, + ) { + DropdownMenuItem( + text = { Text("Copy Text") }, + onClick = { + copyToClipboard(event.content) + state.dismiss() + }, + ) + DropdownMenuItem( + text = { Text("Copy Note ID") }, + onClick = { + copyToClipboard("nostr:${NNote.create(event.id)}") + state.dismiss() + }, + ) + DropdownMenuItem( + text = { Text("Copy Event Link") }, + onClick = { + val relays = relayManager.connectedRelays.value.take(3) + copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}") + state.dismiss() + }, + ) + DropdownMenuItem( + text = { Text("Copy Raw JSON") }, + onClick = { + copyToClipboard(event.toJson()) + state.dismiss() + }, + ) + DropdownMenuItem( + text = { Text("Copy Web Link") }, + onClick = { + val nevent = NEvent.create(event.id, event.pubKey, event.kind, emptyList()) + copyToClipboard("https://njump.me/$nevent") + state.dismiss() + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Broadcast") }, + onClick = { + relayManager.broadcastToAll(event) + state.dismiss() + }, + ) + } +} + +private fun copyToClipboard(text: String) { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) +} From 9194dac8f94b7c5ad932ec6bbe4e432fb3c717bc Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 11:07:31 +0300 Subject: [PATCH 06/17] feat(desktop): related content section in thread view - Create CompactNoteData @Immutable data class in commons for reuse - Create RelatedContentSection composable with horizontal LazyRow - Scan LocalCache for hashtag-matching + same-author notes - Compact cards (160dp) with title, author, zap count - Wire into ThreadScreen below reply notes - Hidden when no related content found - Subscriptions cancel on dispose Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commons/feeds/related/CompactNoteData.kt | 36 ++++ .../desktop/ui/thread/RelatedContentRow.kt | 198 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/related/CompactNoteData.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/related/CompactNoteData.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/related/CompactNoteData.kt new file mode 100644 index 0000000000..0c05649270 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/related/CompactNoteData.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.related + +import androidx.compose.runtime.Immutable + +/** + * Compact display data for a related content card. + * Marked @Immutable for Compose stability — all fields are val primitives/String. + */ +@Immutable +data class CompactNoteData( + val id: String, + val title: String, + val authorName: String, + val thumbnailUrl: String?, + val zapCount: String, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt new file mode 100644 index 0000000000..e0369ea975 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt @@ -0,0 +1,198 @@ +/* + * 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.Arrangement +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.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.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.feeds.related.CompactNoteData +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.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + +/** + * Horizontal scrollable row of compact related content cards. + * Shows related posts by hashtag and author for the given note. + * Only loads when the thread view is open. + */ +@Composable +fun RelatedContentSection( + noteId: String, + authorPubKey: String, + noteHashtags: Set, + localCache: DesktopLocalCache, + onItemClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var relatedItems by remember(noteId) { mutableStateOf>(emptyList()) } + + DisposableEffect(noteId) { + val results = mutableListOf() + val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet() + val limit = 6 + + // Scan cache for related content + if (lowercaseTags.isNotEmpty()) { + localCache.notes.forEach { key, note -> + if (note.idHex != noteId && + note.event is TextNoteEvent && + note.event?.tags?.isTaggedHashes(lowercaseTags) == true + ) { + results.add(note) + } + } + } + + // Fallback: same author + if (results.size < limit) { + localCache.notes.forEach { key, note -> + if (note.idHex != noteId && + note.event is TextNoteEvent && + note.event?.pubKey == authorPubKey && + note !in results + ) { + results.add(note) + } + } + } + + relatedItems = + results + .sortedByDescending { it.createdAt() } + .take(limit) + .map { note -> + val event = note.event + val content = event?.content?.take(80) ?: "" + val firstLine = content.lineSequence().firstOrNull()?.take(60) ?: "" + val author = localCache.getUserIfExists(event?.pubKey ?: "") + CompactNoteData( + id = note.idHex, + title = firstLine.ifBlank { "Note" }, + authorName = author?.toBestDisplayName() ?: event?.pubKey?.take(8) ?: "", + thumbnailUrl = null, + zapCount = if (note.zapsAmount > java.math.BigDecimal.ZERO) "${note.zapsAmount.toLong()}" else "", + ) + } + + onDispose { } + } + + if (relatedItems.isNotEmpty()) { + val primaryHashtag = noteHashtags.firstOrNull() + Column(modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text( + text = if (primaryHashtag != null) "Related from #$primaryHashtag" else "More from this author", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(relatedItems, key = { it.id }) { item -> + CompactRelatedCard( + item = item, + onClick = { onItemClick(item.id) }, + ) + } + } + } + } +} + +@Composable +private fun CompactRelatedCard( + item: CompactNoteData, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = + modifier + .width(160.dp) + .clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = item.authorName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.zapCount.isNotBlank()) { + Spacer(Modifier.height(2.dp)) + Row { + Icon( + MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.height(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = "${item.zapCount} sats", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} From 5aa2f519e7cacb5d641303f63bfa9b38832b3bda Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 11:34:18 +0300 Subject: [PATCH 07/17] feat(desktop): visual overhaul of thread detail view matching Layers design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create CommentsCard: OutlinedCard with "Comments N" header + badge, "Most recent" label, reply input slot, comment items slot - Create CommentItem: lightweight comment row with avatar, name, handle, time, content, Reply/Like/Zap actions (replaces heavy FeedNoteCard for replies) - Restyle InlineReplyInput: cyan "Send" pill button instead of plain icon - Revise RelatedContentRow: image-overlay cards (200x140dp) with AsyncImage background, dark gradient overlay, white title + author + zaps - Restructure ThreadScreen: root note card → CommentsCard → Related section Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/ThreadScreen.kt | 166 +++++++++-------- .../amethyst/desktop/ui/thread/CommentItem.kt | 171 +++++++++++++++++ .../desktop/ui/thread/CommentsCard.kt | 103 ++++++++++ .../desktop/ui/thread/InlineReplyInput.kt | 29 +-- .../desktop/ui/thread/RelatedContentRow.kt | 176 +++++++++++++----- 5 files changed, 513 insertions(+), 132 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 1d920ded56..bdaa9da6c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -57,7 +55,7 @@ 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.feeds.DesktopThreadFilter @@ -70,6 +68,8 @@ 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 @@ -321,85 +321,95 @@ fun ThreadScreen( } } - // Inline reply input - if (account != null && rootNote != null) { - item(key = "inline-reply") { - val myPubKey = account.pubKeyHex - val myUser = remember(myPubKey) { localCache.getUserIfExists(myPubKey) } - val myAvatarUrl = remember(myUser) { myUser?.profilePicture() } + // 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 rootEvent = rootNote.event ?: return@withContext - val template = - TextNoteEvent.build(content) { - val etag = ETag(rootEvent.id) - etag.relay = null - etag.author = rootEvent.pubKey - eTag(etag) - pTag(PTag(rootEvent.pubKey, relayHint = null)) + InlineReplyInput( + myAvatarUrl = myAvatarUrl, + onSend = { content -> + withContext(Dispatchers.IO) { + val rootEvent = + rootNote.event ?: return@withContext + val template = + TextNoteEvent.build(content) { + val etag = ETag(rootEvent.id) + etag.relay = null + etag.author = rootEvent.pubKey + eTag(etag) + pTag( + PTag( + rootEvent.pubKey, + relayHint = null, + ), + ) + } + val signedEvent = account.signer.sign(template) + localCache.consume(signedEvent, relay = null) + relayManager.broadcastToAll(signedEvent) } - val signedEvent = account.signer.sign(template) - localCache.consume(signedEvent, relay = null) - relayManager.broadcastToAll(signedEvent) - } - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - HorizontalDivider(thickness = 1.dp) - } - } - - // 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) } - }, + }, + ) + } + }, ) { - 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() - }, - ) - } - HorizontalDivider(thickness = 1.dp) - } + 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 + val author = + remember(event?.pubKey) { + event?.pubKey?.let { localCache.getUserIfExists(it) } + } - // 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), - ) + // Observe reactions for this reply + val flowSet = remember(note) { note.flow() } + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() + + DisposableEffect(note) { onDispose { note.clearFlow() } } + + 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(), + onAuthorClick = { + event?.pubKey?.let { onNavigateToProfile(it) } + }, + ) + if (index < replyNotes.lastIndex) { + Spacer(Modifier.height(12.dp)) + } + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt new file mode 100644 index 0000000000..16d8b6de08 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt @@ -0,0 +1,171 @@ +/* + * 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 + +@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 = formatZapAmount(zapAmount), + style = MaterialTheme.typography.labelSmall, + color = zapColor, + ) + } + } + } + } + } +} + +private fun formatZapAmount(sats: Long): String = + when { + sats >= 1_000_000 -> "${sats / 1_000_000}M" + sats >= 1_000 -> "${sats / 1_000}k" + else -> sats.toString() + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt new file mode 100644 index 0000000000..417887335b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentsCard.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.thread + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun CommentsCard( + commentCount: Int, + replyContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val outlineVariant = MaterialTheme.colorScheme.outlineVariant + val cardBorder = remember(outlineVariant) { BorderStroke(1.dp, outlineVariant) } + val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface) + + OutlinedCard( + modifier = modifier.fillMaxWidth(), + border = cardBorder, + colors = cardColors, + shape = RoundedCornerShape(12.dp), + ) { + Column(Modifier.padding(16.dp)) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Comments", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(8.dp)) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = commentCount.toString(), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } + + Spacer(Modifier.height(4.dp)) + Text( + text = "Most recent", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + // Reply input slot + replyContent() + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // Comment items + content() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt index 3f4ef3fe76..17cf1dc3f3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/InlineReplyInput.kt @@ -24,11 +24,14 @@ 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -141,28 +144,30 @@ fun InlineReplyInput( maxLines = 5, ) Spacer(Modifier.width(8.dp)) - IconButton( + Button( onClick = { doSend() }, enabled = text.isNotBlank() && !isSending, - modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(20.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.height(36.dp), ) { if (isSending) { CircularProgressIndicator( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(16.dp), strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, ) } else { Icon( MaterialSymbols.AutoMirrored.Send, - contentDescription = "Send reply", - modifier = Modifier.size(20.dp), - tint = - if (text.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + contentDescription = null, + modifier = Modifier.size(16.dp), ) + Spacer(Modifier.width(4.dp)) + Text("Send", style = MaterialTheme.typography.labelMedium) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt index e0369ea975..7f21475beb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt @@ -20,12 +20,15 @@ */ 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 @@ -42,13 +45,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +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.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -105,14 +115,24 @@ fun RelatedContentSection( .take(limit) .map { note -> val event = note.event - val content = event?.content?.take(80) ?: "" - val firstLine = content.lineSequence().firstOrNull()?.take(60) ?: "" + 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 = null, + thumbnailUrl = imageUrl, zapCount = if (note.zapsAmount > java.math.BigDecimal.ZERO) "${note.zapsAmount.toLong()}" else "", ) } @@ -123,12 +143,38 @@ fun RelatedContentSection( if (relatedItems.isNotEmpty()) { val primaryHashtag = noteHashtags.firstOrNull() Column(modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Text( - text = if (primaryHashtag != null) "Related from #$primaryHashtag" else "More from this author", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.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 { /* TODO: navigate to full related list */ }, + ) + } LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -151,47 +197,93 @@ private fun CompactRelatedCard( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val shape = MaterialTheme.shapes.medium Card( modifier = modifier - .width(160.dp) + .width(200.dp) + .height(140.dp) .clickable(onClick = onClick), + shape = shape, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + containerColor = MaterialTheme.colorScheme.surfaceVariant, ), ) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = item.title, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface, + 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), + ), + ), + ), ) - Spacer(Modifier.height(4.dp)) - Text( - text = item.authorName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (item.zapCount.isNotBlank()) { + + // 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)) - Row { - Icon( - MaterialSymbols.Bolt, - contentDescription = null, - modifier = Modifier.height(12.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = "${item.zapCount} sats", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) - } + 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, + ) } } } From 92e210a5840a91fe3be3368c961396708658f843 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 11:45:32 +0300 Subject: [PATCH 08/17] fix(desktop): follow pill visibility, metadata loading, reply + view all wiring - Fix follow pill layout: author row uses weight(1f) so pill has room (was invisible due to SpaceBetween squeezing) - Fix comment metadata: observe metadataState so author info recomposes when kind:0 arrives from relay - Wire "View all" on related content to navigate to author profile - Wire reply button on CommentItem to open reply compose dialog Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/ThreadScreen.kt | 15 +++++++++----- .../amethyst/desktop/ui/note/NoteCard.kt | 20 +++++++++++-------- .../desktop/ui/thread/RelatedContentRow.kt | 3 ++- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index bdaa9da6c9..7adaa71906 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -371,18 +371,21 @@ fun ThreadScreen( } else { replyNotes.forEachIndexed { index, note -> val event = note.event - val author = - remember(event?.pubKey) { - event?.pubKey?.let { localCache.getUserIfExists(it) } - } - // Observe reactions for this reply + // 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 } @@ -401,6 +404,7 @@ fun ThreadScreen( timeAgo = (event?.createdAt ?: 0L).toTimeAgo(), reactionCount = reactionCount, zapAmount = zapAmount.toLong(), + onReply = { event?.let { onReply(it) } }, onAuthorClick = { event?.pubKey?.let { onNavigateToProfile(it) } }, @@ -428,6 +432,7 @@ fun ThreadScreen( noteHashtags = noteHashtags, localCache = localCache, onItemClick = onNavigateToThread, + onViewAll = { onNavigateToProfile(rootEvent.pubKey) }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 77344c53ba..12eac87cf2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -163,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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt index 7f21475beb..fdf6959cb2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt @@ -75,6 +75,7 @@ fun RelatedContentSection( noteHashtags: Set, localCache: DesktopLocalCache, onItemClick: (String) -> Unit, + onViewAll: () -> Unit = {}, modifier: Modifier = Modifier, ) { var relatedItems by remember(noteId) { mutableStateOf>(emptyList()) } @@ -172,7 +173,7 @@ fun RelatedContentSection( text = "View all >", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.clickable { /* TODO: navigate to full related list */ }, + modifier = Modifier.clickable(onClick = onViewAll), ) } From 4fddfef5dd078adad2cccfb0f7aa26d08272d357 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 12:03:16 +0300 Subject: [PATCH 09/17] feat(desktop): inline card expansion in feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add expandedNoteId state to FeedScreen — clicking a card expands it in-place instead of navigating to separate ThreadScreen - AnimatedVisibility(expandVertically + fadeIn) for smooth expansion - ExpandedNoteContent composable renders CommentsCard + RelatedContentSection below the expanded card within the same LazyColumn item - Auto-scroll expanded card to top of viewport - Thread reply subscriptions start on expand, cancel on collapse - Only one card expanded at a time — clicking another collapses current - Search bar stays visible (floating header above LazyColumn) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 194 +++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index dbf3d7552e..7d8b21b190 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -97,6 +97,7 @@ 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 @@ -116,6 +117,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 @@ -123,10 +125,20 @@ 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.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -140,6 +152,7 @@ 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, @@ -394,6 +407,9 @@ fun FeedScreen( var replyToEvent by remember { mutableStateOf(null) } var lightboxState by remember { mutableStateOf(null) } + // Inline expansion state — which note is expanded to show comments + related + var expandedNoteId by remember { mutableStateOf(null) } + // Follow pill state val scope = rememberCoroutineScope() val followMutex = remember { Mutex() } @@ -519,7 +535,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() } } @@ -733,6 +749,16 @@ fun FeedScreen( } } + // Auto-scroll expanded card to top + LaunchedEffect(expandedNoteId) { + if (expandedNoteId != null) { + val index = loadedState.list.indexOfFirst { it.idHex == expandedNoteId } + if (index >= 0) { + lazyListState.animateScrollToItem(index) + } + } + } + val sidePadding = LocalReadingSidePadding.current LazyColumn( state = lazyListState, @@ -740,6 +766,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, @@ -749,7 +777,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) }, @@ -763,6 +794,28 @@ fun FeedScreen( 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 + }, + onReply = { replyToEvent = it }, + onZapFeedback = onZapFeedback, + ) + } } } } @@ -1434,6 +1487,143 @@ 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 = {}, + onReply: (Event) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, +) { + val event = note.event ?: return + val noteId = event.id + 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 + } + } + + // Get reply notes from cache + val replyNotes = remember(note.replies) { note.replies.sortedByDescending { it.createdAt() } } + + // Load metadata for reply authors + LaunchedEffect(replyNotes, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && replyNotes.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForNotes(replyNotes) + } + } + + 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 template = + TextNoteEvent.build(content) { + val etag = ETag(event.id) + etag.relay = null + etag.author = event.pubKey + eTag(etag) + pTag( + PTag(event.pubKey, relayHint = null), + ) + } + val signedEvent = account.signer.sign(template) + localCache.consume(signedEvent, relay = null) + relayManager.broadcastToAll(signedEvent) + } + }, + ) + } + }, + ) { + 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 = { replyEvent?.let { onReply(it) } }, + onAuthorClick = { replyEvent?.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 = onNavigateToThread, + onViewAll = { onNavigateToProfile(event.pubKey) }, + ) + } +} + @Composable private fun FollowPill( onClick: () -> Unit, From 08c7b5f214591a3946296f5a91c6f0fed6b58a36 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 12:08:35 +0300 Subject: [PATCH 10/17] fix(desktop): remove auto-scroll on card expansion Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 7d8b21b190..6acbd7d47e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -749,16 +749,6 @@ fun FeedScreen( } } - // Auto-scroll expanded card to top - LaunchedEffect(expandedNoteId) { - if (expandedNoteId != null) { - val index = loadedState.list.indexOfFirst { it.idHex == expandedNoteId } - if (index >= 0) { - lazyListState.animateScrollToItem(index) - } - } - } - val sidePadding = LocalReadingSidePadding.current LazyColumn( state = lazyListState, From 3f862637d11b3396b52619d4acc6ad2b0d094c39 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 12:09:28 +0300 Subject: [PATCH 11/17] fix(desktop): load comment author metadata on inline expansion - Observe note.flow().replies so replyNotes recomputes when replies arrive - Use loadMetadataBatched with explicit author pubkeys from reply events - DisposableEffect for proper flow cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 6acbd7d47e..2c0c483506 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1513,13 +1513,22 @@ private fun ExpandedNoteContent( } } - // Get reply notes from cache - val replyNotes = remember(note.replies) { note.replies.sortedByDescending { it.createdAt() } } + // 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()) { - subscriptionsCoordinator.loadMetadataForNotes(replyNotes) + val authors = replyNotes.mapNotNull { it.event?.pubKey }.distinct() + if (authors.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataBatched(authors) + } } } From 4b021351d3f3ca45742358879d5bd2a6161cb5c8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 12:13:33 +0300 Subject: [PATCH 12/17] fix(desktop): wire comment reactions + fix related content click navigation - Wire onLike on CommentItem: ReactionAction.reactTo + broadcast - Related content clicks use overlay navigation (ThreadScreen) since related notes may not be in the feed LazyColumn - Add onNavigateToThreadOverlay param to ExpandedNoteContent - Zap from comments deferred (requires full NWC flow) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 2c0c483506..ba87767ccc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -86,6 +86,7 @@ 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 @@ -131,6 +132,7 @@ 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.events.ETag @@ -802,6 +804,7 @@ fun FeedScreen( onNavigateToThread = { noteId -> expandedNoteId = if (expandedNoteId == noteId) null else noteId }, + onNavigateToThreadOverlay = onNavigateToThread, onReply = { replyToEvent = it }, onZapFeedback = onZapFeedback, ) @@ -1487,11 +1490,13 @@ private fun ExpandedNoteContent( 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() @@ -1598,6 +1603,20 @@ private fun ExpandedNoteContent( reactionCount = reactionCount, zapAmount = zapAmount.toLong(), onReply = { replyEvent?.let { onReply(it) } }, + onLike = { + if (account != null && replyEvent != null) { + expandedScope.launch(Dispatchers.IO) { + val signed = + ReactionAction.reactTo( + EventHintBundle(replyEvent, null), + "+", + account.signer, + ) + relayManager.broadcastToAll(signed) + } + } + }, + onZap = { /* Zap from comment requires NWC flow — use card actions */ }, onAuthorClick = { replyEvent?.pubKey?.let { onNavigateToProfile(it) } }, ) if (index < replyNotes.take(5).lastIndex) { @@ -1617,7 +1636,7 @@ private fun ExpandedNoteContent( authorPubKey = event.pubKey, noteHashtags = noteHashtags, localCache = localCache, - onItemClick = onNavigateToThread, + onItemClick = onNavigateToThreadOverlay, onViewAll = { onNavigateToProfile(event.pubKey) }, ) } From 1b17ce6975620322855fcfd00c76a1737b08ed30 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 12:20:01 +0300 Subject: [PATCH 13/17] fix(desktop): wire like and zap on comment items - Fix like: read replyNote.event inside lambda (not captured val) to avoid stale null reference. Consume reaction into local cache. - Wire zap on comments: uses zapNote (now internal) with 21 sats default via NWC connection, same flow as main action row - Wire like/zap in both FeedScreen (inline expansion) and ThreadScreen Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/FeedScreen.kt | 28 ++++++++++--- .../amethyst/desktop/ui/NoteActions.kt | 2 +- .../amethyst/desktop/ui/ThreadScreen.kt | 39 ++++++++++++++++++- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ba87767ccc..ee927efd01 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1602,22 +1602,40 @@ private fun ExpandedNoteContent( timeAgo = (replyEvent?.createdAt ?: 0L).toTimeAgo(), reactionCount = reactionCount, zapAmount = zapAmount.toLong(), - onReply = { replyEvent?.let { onReply(it) } }, + onReply = { replyNote.event?.let { onReply(it) } }, onLike = { - if (account != null && replyEvent != null) { + val ev = replyNote.event + if (account != null && ev != null) { expandedScope.launch(Dispatchers.IO) { val signed = ReactionAction.reactTo( - EventHintBundle(replyEvent, null), + EventHintBundle(ev, null), "+", account.signer, ) relayManager.broadcastToAll(signed) + localCache.consume(signed, relay = null) } } }, - onZap = { /* Zap from comment requires NWC flow — use card actions */ }, - onAuthorClick = { replyEvent?.pubKey?.let { onNavigateToProfile(it) } }, + 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)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 367dcad3a8..b69da8aae7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1483,7 +1483,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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 7adaa71906..9c6894d4e9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -44,6 +44,7 @@ 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 @@ -51,6 +52,7 @@ 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.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 @@ -74,6 +76,7 @@ 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.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.eTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -84,6 +87,7 @@ 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 /** @@ -108,6 +112,7 @@ fun ThreadScreen( ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays = relayStatuses.keys + val threadScope = rememberCoroutineScope() // Lightbox state var lightboxState by remember { mutableStateOf(null) } @@ -404,9 +409,39 @@ fun ThreadScreen( timeAgo = (event?.createdAt ?: 0L).toTimeAgo(), reactionCount = reactionCount, zapAmount = zapAmount.toLong(), - onReply = { event?.let { onReply(it) } }, + 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, + ) + relayManager.broadcastToAll(signed) + localCache.consume(signed, relay = null) + } + } + }, + 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 = { - event?.pubKey?.let { onNavigateToProfile(it) } + note.event?.pubKey?.let { onNavigateToProfile(it) } }, ) if (index < replyNotes.lastIndex) { From 8d715e573070d7d7059ed2e1635a7ac9ac352527 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 31 May 2026 12:36:52 +0200 Subject: [PATCH 14/17] feat(dm-share): add ShareToDM route and attachment param on Room route --- amethyst/src/main/AndroidManifest.xml | 25 +++++ .../amethyst/ui/navigation/AppNavigation.kt | 32 ++++-- .../ui/navigation/ShareIntentRouting.kt | 33 ++++++ .../ui/navigation/routes/RouteMaker.kt | 2 +- .../amethyst/ui/navigation/routes/Routes.kt | 9 +- .../chats/privateDM/ChatroomScreen.kt | 2 + .../loggedIn/chats/privateDM/ChatroomView.kt | 18 ++++ .../chats/share/ShareDMRoomsFeedFilter.kt | 54 ++++++++++ .../loggedIn/chats/share/ShareToDMNav.kt | 41 +++++++ .../chats/share/ShareToDMRouteRewriter.kt | 40 +++++++ .../loggedIn/chats/share/ShareToDMScreen.kt | 102 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 3 + .../navigation/ShareIntentRoutingTest.kt | 49 +++++++++ .../navigation/ShareToDMRouteRewriterTest.kt | 57 ++++++++++ 14 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMRouteRewriter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareToDMRouteRewriterTest.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index f49058bee7..c9dd72cb5b 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -218,6 +218,31 @@ + + + + + + + + + + + + + + + + + + + + + { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEnd { MarmotGroupListScreen(accountViewModel, nav) } @@ -461,6 +462,7 @@ fun BuildNavigation( composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } + composableFromBottomArgs { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) } composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } @@ -593,9 +595,15 @@ private fun NavigateIfIntentRequested( val activity = LocalContext.current.getActivity() if (activity.intent.action == Intent.ACTION_SEND) { - // avoids restarting the new Post screen when the intent is for the screen. + val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className) + + // avoids restarting the destination screen when the intent is for the screen. // Microsoft's swift key sends Gifs as new actions - if (isBaseRoute(nav.controller)) return + if (isShareAsDm) { + if (isBaseRoute(nav.controller)) return + } else { + if (isBaseRoute(nav.controller)) return + } // saves the intent to avoid processing again var message by remember { @@ -612,7 +620,11 @@ 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())) + } } else { var newAccount by remember { mutableStateOf(null) } @@ -671,9 +683,17 @@ private fun NavigateIfIntentRequested( val consumer = Consumer { intent -> if (intent.action == Intent.ACTION_SEND) { - // avoids restarting the new Post screen when the intent is for the screen. + val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className) + // avoids restarting the destination screen when the intent is for the screen. // Microsoft's swift key sends Gifs as new actions - if (!isBaseRoute(nav.controller)) { + if (isShareAsDm) { + if (!isBaseRoute(nav.controller)) { + val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null } + val attachment = + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString() + nav.newStack(Route.ShareToDM(message = message, attachment = attachment)) + } + } else if (!isBaseRoute(nav.controller)) { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { nav.newStack(Route.NewShortNote(message = it)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt new file mode 100644 index 0000000000..92cc13b41e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt @@ -0,0 +1,33 @@ +/* + * 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. */ + const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias" + + fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(SHARE_AS_DM_ALIAS_SIMPLE_NAME) == true +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 6d90c38983..48ef26c9b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -234,7 +234,7 @@ fun routeToMessage( ): Route { account.chatroomList.getOrCreatePrivateChatroom(room) - return Route.Room(room, draftMessage, replyId, draftId, expiresDays) + return Route.Room(room, message = draftMessage, replyId = replyId, draftId = draftId, expiresDays = expiresDays) } fun routeToMessage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3c8579065a..f0faa2f41e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -523,16 +523,23 @@ sealed class Route { val attachment: String? = null, ) : Route() + @Serializable data class ShareToDM( + val message: String? = null, + val attachment: String? = null, + ) : Route() + @Serializable data class Room( val id: String, val message: String? = null, + val attachment: String? = null, val replyId: HexKey? = null, val draftId: HexKey? = null, val expiresDays: Int? = null, ) : Route() { - constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this( + constructor(key: ChatroomKey, message: String? = null, attachment: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this( id = key.users.joinToString(","), message = message, + attachment = attachment, replyId = replyId, draftId = draftId, expiresDays = expiresDays, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index aa67b6bf22..637f064cbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType fun ChatroomScreen( roomId: ChatroomKey, draftMessage: String? = null, + attachmentUri: String? = null, replyToNote: HexKey? = null, editFromDraft: HexKey? = null, expiresDays: Int? = null, @@ -86,6 +87,7 @@ fun ChatroomScreen( ChatroomView( room = roomId, draftMessage = draftMessage, + attachmentUri = attachmentUri, replyToNote = replyToNote, editFromDraft = editFromDraft, expiresDays = expiresDays, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 5103d1e790..a0f1c4df76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -29,9 +29,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -46,12 +49,16 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun ChatroomView( room: ChatroomKey, draftMessage: String?, + attachmentUri: String? = null, replyToNote: HexKey? = null, editFromDraft: HexKey? = null, expiresDays: Int? = null, @@ -112,6 +119,17 @@ fun ChatroomView( newPostModel.onMessageChanged() } } + val context = LocalContext.current + if (attachmentUri != null) { + LaunchedEffect(key1 = attachmentUri) { + attachmentUri.ifBlank { null }?.toUri()?.let { uri -> + withContext(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(uri) + newPostModel.pickedMedia(persistentListOf(SelectedMedia(uri, mediaType))) + } + } + } + } ChatroomViewUI( room = room, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt new file mode 100644 index 0000000000..6a7a20842c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareDMRoomsFeedFilter.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share + +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder + +/** + * Recent private-DM conversations only (no public channels, ephemeral chats, or + * marmot groups). Backs the Share-to-DM picker. Read-only/transient — extends the + * non-additive [FeedFilter] base because the picker loads once and does not need + * live additive updates. + */ +class ShareDMRoomsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun feed(): List { + val chatList = account.chatroomList + val followingKeySet = account.followingKeySet() + + return chatList.rooms + .mapNotNull { key, chatroom -> + if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) && + !account.isAllHidden(key.users) + ) { + chatroom.newestMessage + } else { + null + } + }.sortedWith(DefaultFeedOrder) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt new file mode 100644 index 0000000000..b2a4a157d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt @@ -0,0 +1,41 @@ +/* + * 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) { + delegate.nav(ShareToDMRouteRewriter.rewrite(route, message, attachment)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMRouteRewriter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMRouteRewriter.kt new file mode 100644 index 0000000000..5f2743f48c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMRouteRewriter.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share + +import com.vitorpamplona.amethyst.ui.navigation.routes.Route + +/** + * Injects shared content into a chatroom navigation so that tapping a recent + * conversation in the Share-to-DM picker opens the composer pre-filled. + */ +object ShareToDMRouteRewriter { + fun rewrite( + route: Route, + message: String?, + attachment: String?, + ): Route = + if (route is Route.Room) { + route.copy(message = message, attachment = attachment) + } else { + route + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt new file mode 100644 index 0000000000..24c132066c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt @@ -0,0 +1,102 @@ +/* + * 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, +) { + 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.nav(Route.NewGroupDM(message = message, attachment = attachment)) } + .padding(16.dp), + ) + + HorizontalDivider(thickness = DividerThickness) + + ChatroomListFeedView( + feedContentState = feedContentState, + scrollStateKey = "ShareToDM", + accountViewModel = accountViewModel, + nav = shareNav, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a3badeea6e..7da1484f55 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1661,6 +1661,9 @@ Copy nprofile to clipboard Copy npub to clipboard Share or Save + Send as DM + Send to… + New message Copy URL to clipboard Copy Note ID to clipboard Add Media to Gallery diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt new file mode 100644 index 0000000000..4022e9ff37 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareToDMRouteRewriterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareToDMRouteRewriterTest.kt new file mode 100644 index 0000000000..1e03beb595 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareToDMRouteRewriterTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.navigation + +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMRouteRewriter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ShareToDMRouteRewriterTest { + @Test + fun injectsMessageAndAttachmentIntoRoomRoute() { + val original = Route.Room(id = "pubkeyA,pubkeyB") + val result = ShareToDMRouteRewriter.rewrite(original, "hello", "content://media/1") + + result as Route.Room + assertEquals("pubkeyA,pubkeyB", result.id) + assertEquals("hello", result.message) + assertEquals("content://media/1", result.attachment) + } + + @Test + fun preservesExistingRoomFields() { + val original = Route.Room(id = "x", replyId = "reply1", expiresDays = 3) + val result = ShareToDMRouteRewriter.rewrite(original, "hi", null) as Route.Room + + assertEquals("reply1", result.replyId) + assertEquals(3, result.expiresDays) + assertEquals("hi", result.message) + } + + @Test + fun leavesNonRoomRoutesUnchanged() { + val original = Route.Home + val result = ShareToDMRouteRewriter.rewrite(original, "hi", "uri") + assertSame(original, result) + } +} From e6a512db42413c8ada7b246a66a67ae714e4d1fb Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 31 May 2026 21:50:12 +0200 Subject: [PATCH 15/17] Code review and testing fixes: - fix(dm-share): kotlin-review fixes (alias dot-boundary match + transient feed doc) - fix(dm-share): address code-review findings (intent consume, media helper, manifest sync) - fix(dm-share): make the picker one-shot so backing out doesn't duplicate drafts - fix(dm): avoid duplicate drafts on abort by rotating draft tag after the async save --- amethyst/src/main/AndroidManifest.xml | 3 ++ .../ui/actions/uploads/SharedMediaResolver.kt | 42 +++++++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 8 ++++ .../ui/navigation/ShareIntentRouting.kt | 9 +++- .../loggedIn/chats/privateDM/ChatroomView.kt | 12 ++---- .../send/PrivateMessageEditFieldRow.kt | 8 +++- .../loggedIn/chats/share/ShareToDMNav.kt | 10 ++++- .../loggedIn/chats/share/ShareToDMScreen.kt | 8 +++- .../navigation/ShareIntentRoutingTest.kt | 5 +++ 9 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SharedMediaResolver.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index c9dd72cb5b..065754cc4f 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -218,6 +218,9 @@ + + withContext(Dispatchers.IO) { + SelectedMedia(uri, context.contentResolver.getType(uri)) + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1b4a577dc5..b05ec5d244 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -625,6 +625,14 @@ private fun NavigateIfIntentRequested( } else { nav.newStack(Route.NewShortNote(message = message, attachment = media.toString())) } + + // Consume the launch intent so a later recomposition can't re-fire + // newStack for the same share (the isBaseRoute guard is a non-reactive + // snapshot and stops guarding once we navigate past the destination, + // e.g. into a chat via the one-shot picker). Clearing the action also + // lets the else-branch register the onNewIntent listener for the rest + // of this session. + activity.intent.action = null } else { var newAccount by remember { mutableStateOf(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt index 92cc13b41e..c7d3f06194 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/ShareIntentRouting.kt @@ -26,8 +26,13 @@ package com.vitorpamplona.amethyst.ui.navigation * component class name of the launching intent (the activity-alias name). */ object ShareIntentRouting { - /** Simple class name of the activity-alias declared in AndroidManifest.xml. */ + /** + * Simple class name of the `` declared in AndroidManifest.xml + * (android:name=".ui.ShareAsDMAlias"). MUST stay in sync with the manifest — + * renaming the alias there without updating this constant silently routes + * "Send as DM" shares to the New Post composer (no build error). + */ const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias" - fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(SHARE_AS_DM_ALIAS_SIMPLE_NAME) == true + fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index a0f1c4df76..06d9bf0532 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -31,10 +31,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +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 @@ -50,9 +49,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @Composable fun ChatroomView( @@ -122,11 +119,8 @@ fun ChatroomView( val context = LocalContext.current if (attachmentUri != null) { LaunchedEffect(key1 = attachmentUri) { - attachmentUri.ifBlank { null }?.toUri()?.let { uri -> - withContext(Dispatchers.IO) { - val mediaType = context.contentResolver.getType(uri) - newPostModel.pickedMedia(persistentListOf(SelectedMedia(uri, mediaType))) - } + resolveSharedMedia(context, attachmentUri)?.let { + newPostModel.pickedMedia(persistentListOf(it)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 6c59988b49..a089688b21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -114,9 +114,15 @@ fun PrivateMessageEditFieldRow( if (channelScreenModel.message.text.isNotBlank()) { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() + // Rotate the draft tag only AFTER the async save completes. Doing it + // synchronously here (before launchSigner runs) would make sendDraftSync + // persist under a freshly-rotated tag, duplicating the draft. See the + // matching order in NewGroupDMScreen. + channelScreenModel.cancel() } + } else { + channelScreenModel.cancel() } - channelScreenModel.cancel() nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt index b2a4a157d8..f88a4e936e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMNav.kt @@ -36,6 +36,14 @@ class ShareToDMNav( private val attachment: String?, ) : INav by delegate { override fun nav(route: Route) { - delegate.nav(ShareToDMRouteRewriter.rewrite(route, message, attachment)) + val rewritten = ShareToDMRouteRewriter.rewrite(route, message, attachment) + if (route is Route.Room) { + // One-shot: replace the picker in the back stack so backing out of the + // chat exits the share flow instead of returning to the picker, which + // would re-inject the shared text on re-tap and create duplicate drafts. + delegate.popUpTo(rewritten, Route.ShareToDM::class) + } else { + delegate.nav(rewritten) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt index 24c132066c..4cf7f907a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/share/ShareToDMScreen.kt @@ -55,6 +55,12 @@ fun ShareToDMScreen( 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( @@ -85,7 +91,7 @@ fun ShareToDMScreen( .clickable( role = Role.Button, onClickLabel = stringRes(R.string.share_to_dm_start_new), - ) { nav.nav(Route.NewGroupDM(message = message, attachment = attachment)) } + ) { nav.popUpTo(Route.NewGroupDM(message = message, attachment = attachment), Route.ShareToDM::class) } .padding(16.dp), ) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt index 4022e9ff37..9ffcdd761e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/ShareIntentRoutingTest.kt @@ -46,4 +46,9 @@ class ShareIntentRoutingTest { fun rejectsNull() { assertFalse(ShareIntentRouting.isShareAsDm(null)) } + + @Test + fun rejectsSuffixThatIsNotASimpleName() { + assertFalse(ShareIntentRouting.isShareAsDm("com.evil.XShareAsDMAlias")) + } } From e8a50bfa1105bc03ff5709a76d8411a17af76358 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:33:42 +0000 Subject: [PATCH 16/17] fix: use ephemeral signer for media uploads in anonymous posts When composing an anonymous post (tap pfp to go anon on the short-note or comment screens), media uploads still authorized against the Blossom / NIP-96 server with the real account's signer. The server echoes that pubkey back in the returned media URL (e.g. Blossom's `as=`), linking the real identity to the supposedly anonymous post. Thread an optional `forcedSigner` through the upload chain (MultiOrchestrator -> UploadOrchestrator -> NIP-96/Blossom auth). Both ShortNotePostViewModel and CommentPostViewModel now hold a single ephemeral signer per compose session, reused for every photo/voice upload and for the final anonymous broadcast, so the upload auth event and the post share one throwaway key. signAnonymouslyAndBroadcast accepts that signer so the media author matches the post author. Non-anonymous callers are unaffected (forcedSigner defaults to null). The signer is reset in cancel() so each new compose session gets a fresh anonymous identity. --- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../service/uploads/MultiOrchestrator.kt | 5 ++++ .../service/uploads/UploadOrchestrator.kt | 29 +++++++++++++++---- .../nip22Comments/CommentPostViewModel.kt | 15 +++++++++- .../loggedIn/home/ShortNotePostViewModel.kt | 16 +++++++++- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1c91b076c5..e3dea0f4d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1888,8 +1888,8 @@ class Account( suspend fun signAnonymouslyAndBroadcast( template: EventTemplate, broadcast: List = emptyList(), + anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), ): T { - val anonymousSigner = NostrSignerInternal(KeyPair()) val event = anonymousSigner.sign(template) cache.justConsumeMyOwnEvent(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 12e44faade..4f9af36bd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope @@ -66,6 +67,7 @@ class MultiOrchestrator( stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): Result { coroutineScope { val jobs = @@ -84,6 +86,7 @@ class MultiOrchestrator( stripMetadata, onStrippingFailed, convertGifToMp4 = convertGifToMp4, + forcedSigner = forcedSigner, ) } } @@ -106,6 +109,7 @@ class MultiOrchestrator( stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): Result { coroutineScope { val jobs = @@ -125,6 +129,7 @@ class MultiOrchestrator( stripMetadata, onStrippingFailed, convertGifToMp4 = convertGifToMp4, + forcedSigner = forcedSigner, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index a230b37cf9..ed5b3e7b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -30,7 +30,10 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow @@ -142,6 +145,7 @@ class UploadOrchestrator { contentTypeForResult: String?, originalHash: String?, account: Account, + forcedSigner: NostrSigner?, context: Context, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) @@ -158,7 +162,12 @@ class UploadOrchestrator { onProgress = { percent: Float -> updateState(0.2 + (0.2 * percent), UploadingState.Uploading) }, - httpAuth = account::createHTTPAuthorization, + httpAuth = + if (forcedSigner != null) { + { url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) } + } else { + account::createHTTPAuthorization + }, context = context, ) @@ -187,6 +196,7 @@ class UploadOrchestrator { contentTypeForResult: String?, originalHash: String?, account: Account, + forcedSigner: NostrSigner?, context: Context, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) @@ -201,7 +211,12 @@ class UploadOrchestrator { sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - httpAuth = account::createBlossomUploadAuth, + httpAuth = + if (forcedSigner != null) { + { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } + } else { + account::createBlossomUploadAuth + }, context = context, ) @@ -360,6 +375,7 @@ class UploadOrchestrator { stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) @@ -379,8 +395,8 @@ class UploadOrchestrator { try { return when (server.type) { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) - ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) + ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) } } finally { deleteTempUri(finalUri, uri) @@ -401,6 +417,7 @@ class UploadOrchestrator { stripMetadata: Boolean = true, onStrippingFailed: suspend () -> Boolean = { true }, convertGifToMp4: Boolean = false, + forcedSigner: NostrSigner? = null, ): UploadingFinalState { val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4) @@ -423,8 +440,8 @@ class UploadOrchestrator { try { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) - ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) } } finally { deleteTempUri(encrypted.uri, uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 4f0758c6b8..70c44937c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -71,8 +71,11 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes @@ -215,6 +218,14 @@ open class CommentPostViewModel : var wantsAnonymousPost by mutableStateOf(false) + // A single ephemeral signer reused for the whole compose session so that media + // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed + // by the same throwaway key, instead of leaking the real account's pubkey into the + // upload authorization (and therefore into the returned media URL). + private var anonymousSignerCache: NostrSigner? = null + + fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it } + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -452,7 +463,7 @@ open class CommentPostViewModel : cancel() if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) } else { accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) } @@ -619,6 +630,7 @@ open class CommentPostViewModel : context, stripMetadata = stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) if (results.allGood) { @@ -711,6 +723,7 @@ open class CommentPostViewModel : wantsToAddGeoHash = false wantsSecretEmoji = false wantsAnonymousPost = false + anonymousSignerCache = null forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 990b02a7c5..150353320c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -91,7 +91,10 @@ import com.vitorpamplona.quartz.experimental.zapPolls.minAmount import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -306,6 +309,14 @@ open class ShortNotePostViewModel : // Anonymous Reply var wantsAnonymousPost by mutableStateOf(false) + // A single ephemeral signer reused for the whole compose session so that media + // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed + // by the same throwaway key, instead of leaking the real account's pubkey into the + // upload authorization (and therefore into the returned media URL). + private var anonymousSignerCache: NostrSigner? = null + + fun anonymousSigner(): NostrSigner = anonymousSignerCache ?: NostrSignerInternal(KeyPair()).also { anonymousSignerCache = it } + // Scheduled posting: epoch seconds (UTC) when the post should be published. // Null = post immediately on Send (existing behavior). var scheduledForSec by mutableStateOf(null) @@ -870,7 +881,7 @@ open class ShortNotePostViewModel : } if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) } else if (accountViewModel.settings.useTrackedBroadcasts()) { // Tracked broadcasting with progress feedback (non-blocking) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) @@ -1138,6 +1149,7 @@ open class ShortNotePostViewModel : stripMetadata, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, convertGifToMp4 = convertGifToMp4, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) if (results.allGood) { @@ -1235,6 +1247,7 @@ open class ShortNotePostViewModel : wantsExclusiveGeoPost = false wantsSecretEmoji = false wantsAnonymousPost = false + anonymousSignerCache = null scheduledForSec = null forwardZapTo.value = SplitBuilder() @@ -1467,6 +1480,7 @@ open class ShortNotePostViewModel : account = account, context = appContext, useH265 = false, + forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null, ) when (result) { From aeb49c3cac43cd1ba9adadbdc53b120c3def528c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 2 Jun 2026 13:45:00 +0300 Subject: [PATCH 17/17] fix(desktop): address PR review findings on feed UI refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 issues from davotoula's review on PR #3124: - #3 (protocol): inline reply emitted a minimal e/p tag set instead of NIP-10. Extract `commons/actions/ReplyActions.replyTo` wrapping `TextNoteEvent.build(replyingTo=)` (which already encodes root marker, reply marker, parent root-e-tag carry) + carry parent's p-tag chain via `notify(...)`. Replies to deep-thread notes now thread correctly in Damus/Primal/Coracle. Covered by `ReplyActionsTest`. - #4 (architecture): reaction/follow/reply each inlined `localCache.consume + relayManager.broadcastToAll` in 5 sites with inconsistent ordering. Extract `desktopApp/cache/dispatch(...)` — canonical local-first order — and route all 5 sites through it. - #1 (UX): related-content section scanned the cache once via `DisposableEffect(noteId)` and never refreshed. Switch to `produceState` collecting `DesktopLocalCache.eventStream.newEventBundles`; re-scan only when an arriving bundle contains a candidate (matching hashtag or author). `LargeCache.notes` is a ConcurrentSkipListMap (weakly consistent iterator) so the scan stays safe on the composition coroutine. - #2 (UX): `DeckColumnContainer` re-requested focus on every `currentOverlay` change, stealing focus from sibling columns whenever any column mutated overlay state. Drop to `LaunchedEffect(Unit)` and wrap the column in `key(column.id)` in `DeckLayout` so the one-shot effect survives column reordering. - #5 (consistency): zap totals bypassed the shared `ZapFormatter`. Wire `RelatedContentRow`, `CommentItem`, and `NoteActions` to `commons/util/ZapFormatter.{showAmount,toZapAmount}`; delete `formatZapAmount` and `formatSats` desktop-local helpers. `WalletColumnScreen.formatSats` intentionally kept — locale-aware full precision for wallet balance is by design. Plan: docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../amethyst/commons/actions/ReplyActions.kt | 82 +++ .../commons/actions/ReplyActionsTest.kt | 105 ++++ .../amethyst/desktop/cache/EventDispatch.kt | 43 ++ .../amethyst/desktop/ui/FeedScreen.kt | 35 +- .../amethyst/desktop/ui/NoteActions.kt | 17 +- .../amethyst/desktop/ui/ThreadScreen.kt | 37 +- .../desktop/ui/deck/DeckColumnContainer.kt | 8 +- .../amethyst/desktop/ui/deck/DeckLayout.kt | 47 +- .../amethyst/desktop/ui/thread/CommentItem.kt | 10 +- .../desktop/ui/thread/RelatedContentRow.kt | 159 +++-- ...2-fix-desktop-feed-review-findings-plan.md | 588 ++++++++++++++++++ 11 files changed, 985 insertions(+), 146 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt create mode 100644 docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt new file mode 100644 index 0000000000..28984d4581 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.notify + +/** + * Pure event-building "verbs" for kind:1 short-note replies (NIP-10). + * + * Builds a signed [TextNoteEvent] reply but does NOT publish it. The Amethyst + * Android UI flow does more than these builders — non-UI callers are + * responsible for the rest: + * + * * **Publish.** Hand the returned event to your relay client. Android uses + * `Account.sendMyPublicAndPrivateOutbox`, the desktop deck pipes through + * `dispatch(signed, localCache, relayManager)`, amy uses `Context.publish`. + * * **Writeable check.** Skip the call when the active signer is read-only + * (e.g. an npub-only login). Building will fail at the sign step otherwise. + * * **Parent kind.** Only kind:1 [TextNoteEvent] parents are well-defined here + * — replies to articles / comments belong on the NIP-22 path + * (`CommentEvent.replyBuilder`). Callers must filter; this signature enforces + * it via [EventHintBundle] of `TextNoteEvent`. + * * **Local cache update.** If your caller has a local event cache, feed the + * new event back in so the UI / next read sees the update without a relay + * round-trip. + * + * Canonical entry point for non-UI callers — the underlying + * [TextNoteEvent.build] reply-aware overload handles full NIP-10 tag carry: + * `marker=root` (parent's root e-tag if present, else parent.id), + * `marker=reply` (parent.id), the parent's full p-tag chain plus parent.pubKey, + * and the relay hint from [EventHintBundle]. + */ +object ReplyActions { + /** + * Build a kind:1 [TextNoteEvent] that replies to [parent], wrapping it with + * NIP-10-correct marked e-tags and the parent's p-tag chain. + * + * Returns the signed event ready to be published. The reply preserves the + * parent's root reference so conformant clients can reconstruct the thread. + */ + suspend fun replyTo( + parent: EventHintBundle, + content: String, + signer: NostrSigner, + ): TextNoteEvent { + // Per NIP-10, replies MUST carry the p-tags of the event being replied + // to plus the author's pubkey. TextNoteEvent.build(replyingTo=) only + // emits the e-tag chain — p-tag carry is the caller's responsibility. + val carriedPubKeys = + (parent.event.linkedPubKeys() + parent.event.pubKey) + .distinct() + .map { PTag(it, relayHint = null) } + + val template = + TextNoteEvent.build(content, replyingTo = parent) { + notify(carriedPubKeys) + } + return signer.sign(template) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt new file mode 100644 index 0000000000..e602464be5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ReplyActionsTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + @Test + fun replyToTopLevelParent_setsRootToParentAndCarriesAuthor() = + runTest { + // Alice posts a top-level note (no e-tags = parent IS its own root). + val parent = aliceSigner.sign(TextNoteEvent.build("hello")) + assertTrue(parent.isNewThread(), "parent must be a fresh thread for this case") + + // Bob replies. + val reply = ReplyActions.replyTo(EventHintBundle(parent, null), "hi alice", bobSigner) + + assertEquals(TextNoteEvent.KIND, reply.kind) + assertEquals(bobSigner.pubKey, reply.pubKey) + + // Per `prepareETagsAsReplyTo`: when parent has no root, only a ROOT + // marker is emitted (it doubles as the reply target). No separate + // REPLY marker. `markedReplyTos()` should still resolve to parent.id. + val root = reply.markedRoot() + assertNotNull(root, "reply must carry a NIP-10 root marker") + assertEquals(parent.id, root.eventId, "root marker must point at the top-level parent") + + // p-tag carry must include the parent's author so they're notified. + val pubKeys = reply.tags.mapNotNull(PTag::parseKey) + assertTrue(parent.pubKey in pubKeys, "reply must carry the parent's pubkey in p-tags") + } + + @Test + fun replyToDeepThread_carriesRootForwardAndChainsPTags() = + runTest { + // Build A (root) → B (alice's reply to A) → C (carol's reply to B). + val a = aliceSigner.sign(TextNoteEvent.build("the original")) + + val carolPriv = "0000000000000000000000000000000000000000000000000000000000000009" + val carolSigner = NostrSignerInternal(KeyPair(carolPriv.hexToByteArray())) + + val b = ReplyActions.replyTo(EventHintBundle(a, null), "good point", aliceSigner) + + // C replies to B — must carry A as root (not B), and reply to B. + val c = ReplyActions.replyTo(EventHintBundle(b, null), "agreed", carolSigner) + + val rootC = c.markedRoot() + assertNotNull(rootC, "deep reply must carry root marker") + assertEquals(a.id, rootC.eventId, "deep reply's root must chain through to original") + + val replyC = c.markedReply() + assertNotNull(replyC, "deep reply must carry reply marker") + assertEquals(b.id, replyC.eventId, "deep reply's reply marker must point at immediate parent") + + // p-tag chain: must include both alice (root author / parent author) and parent.pubKey. + val pubKeys = c.tags.mapNotNull(PTag::parseKey).toSet() + assertTrue(aliceSigner.pubKey in pubKeys, "deep reply must carry root author in p-tags") + } + + @Test + fun replyEvent_isSignedAndKind1() = + runTest { + val parent = aliceSigner.sign(TextNoteEvent.build("seed")) + val reply = ReplyActions.replyTo(EventHintBundle(parent, null), "thanks", bobSigner) + + assertEquals(TextNoteEvent.KIND, reply.kind) + assertTrue(reply.id.length == 64, "reply id must be a 32-byte hex") + assertTrue(reply.sig.length == 128, "reply must be signed (64-byte sig hex)") + assertEquals("thanks", reply.content) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt new file mode 100644 index 0000000000..06a9c51f15 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * Canonical local-first dispatch for user-action events on desktop: write to + * the local cache before broadcasting so the UI reflects the action immediately, + * even if relay round-trips fail. + * + * Replaces five inlined `consume + broadcastToAll` couplets that had drifted + * in ordering (reactions/follows did broadcast-then-consume, replies did + * consume-then-broadcast). Use this everywhere a signed event must be both + * persisted locally and pushed to outbox relays. + */ +fun dispatch( + signed: Event, + localCache: DesktopLocalCache, + relayManager: RelayConnectionManager, +) { + localCache.consume(signed, relay = null) + relayManager.broadcastToAll(signed) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ee927efd01..ff25760a79 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -82,6 +82,7 @@ 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 @@ -103,6 +104,7 @@ 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 @@ -135,11 +137,7 @@ 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.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.events.eTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -421,9 +419,8 @@ fun FeedScreen( followMutex.withLock { val currentList = localCache.lastContactListEvent val updatedEvent = FollowAction.follow(pubKeyHex, account.signer, currentList) - relayManager.broadcastToAll(updatedEvent) - // consume updates followedUsers StateFlow + stores the event - localCache.consume(updatedEvent, relay = null) + // consume updates followedUsers StateFlow + stores the event before broadcast + dispatch(updatedEvent, localCache, relayManager) } } } @@ -1550,19 +1547,14 @@ private fun ExpandedNoteContent( myAvatarUrl = myAvatarUrl, onSend = { content -> withContext(Dispatchers.IO) { - 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), - ) - } - val signedEvent = account.signer.sign(template) - localCache.consume(signedEvent, relay = null) - relayManager.broadcastToAll(signedEvent) + val parentText = event as? TextNoteEvent ?: return@withContext + val signedEvent = + ReplyActions.replyTo( + EventHintBundle(parentText, null), + content, + account.signer, + ) + dispatch(signedEvent, localCache, relayManager) } }, ) @@ -1613,8 +1605,7 @@ private fun ExpandedNoteContent( "+", account.signer, ) - relayManager.broadcastToAll(signed) - localCache.consume(signed, relay = null) + dispatch(signed, localCache, relayManager) } } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index b69da8aae7..83806d8395 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -86,6 +86,7 @@ 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 @@ -232,7 +233,7 @@ fun ZapAmountDialog( FilterChip( selected = selectedAmount == amount, onClick = { selectedAmount = amount }, - label = { Text(formatSats(amount)) }, + label = { Text(amount.toZapAmount()) }, ) } } @@ -250,7 +251,7 @@ fun ZapAmountDialog( }, confirmButton = { Button(onClick = { onZap(selectedAmount, message) }) { - Text("Zap ${formatSats(selectedAmount)} sats") + Text("Zap ${selectedAmount.toZapAmount()} sats") } }, dismissButton = { @@ -261,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). */ @@ -369,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), @@ -411,7 +410,7 @@ fun ZapReceiptsDialog( } } Text( - text = "${formatSats(receipt.amountSats)} sats", + text = "${receipt.amountSats.toZapAmount()} sats", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) @@ -526,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, @@ -567,7 +566,7 @@ fun ZapReceiptsPopup( } } Text( - text = "${formatSats(entry.amount)} sats", + text = "${entry.amount.toZapAmount()} sats", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) @@ -1229,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 }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 9c6894d4e9..78f8b224d4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -49,6 +49,7 @@ 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 @@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState 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 @@ -77,11 +79,7 @@ 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.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.events.eTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -342,24 +340,16 @@ fun ThreadScreen( myAvatarUrl = myAvatarUrl, onSend = { content -> withContext(Dispatchers.IO) { - val rootEvent = - rootNote.event ?: return@withContext - val template = - TextNoteEvent.build(content) { - val etag = ETag(rootEvent.id) - etag.relay = null - etag.author = rootEvent.pubKey - eTag(etag) - pTag( - PTag( - rootEvent.pubKey, - relayHint = null, - ), - ) - } - val signedEvent = account.signer.sign(template) - localCache.consume(signedEvent, relay = null) - relayManager.broadcastToAll(signedEvent) + val parentText = + rootNote.event as? TextNoteEvent + ?: return@withContext + val signedEvent = + ReplyActions.replyTo( + EventHintBundle(parentText, null), + content, + account.signer, + ) + dispatch(signedEvent, localCache, relayManager) } }, ) @@ -420,8 +410,7 @@ fun ThreadScreen( "+", account.signer, ) - relayManager.broadcastToAll(signed) - localCache.consume(signed, relay = null) + dispatch(signed, localCache, relayManager) } } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 4a203c74b7..90f0dbce05 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -146,8 +146,12 @@ fun DeckColumnContainer( val currentOverlay = navState.current val focusRequester = remember { FocusRequester() } - // Request focus on nav change so Esc key works - LaunchedEffect(currentOverlay) { + // 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() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 96058bcf88..aba4cbd9c5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon @@ -119,27 +120,31 @@ fun DeckLayout( ) } - DeckColumnContainer( - column = column, - canClose = columns.size > 1, - onClose = { deckState.removeColumn(column.id) }, - onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) }, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - iAccount = iAccount, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, - nip11Fetcher = nip11Fetcher, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToRelays = onNavigateToRelays, - ) + // Key by column id so reorder/remove doesn't re-run the + // child's `LaunchedEffect(Unit)` (which grabs keyboard focus). + key(column.id) { + DeckColumnContainer( + column = column, + canClose = columns.size > 1, + onClose = { deckState.removeColumn(column.id) }, + onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) }, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, + nip11Fetcher = nip11Fetcher, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToRelays = onNavigateToRelays, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt index 16d8b6de08..7097592a4a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/CommentItem.kt @@ -37,6 +37,7 @@ 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( @@ -152,7 +153,7 @@ fun CommentItem( if (zapAmount > 0) { Spacer(Modifier.width(4.dp)) Text( - text = formatZapAmount(zapAmount), + text = zapAmount.toZapAmount(), style = MaterialTheme.typography.labelSmall, color = zapColor, ) @@ -162,10 +163,3 @@ fun CommentItem( } } } - -private fun formatZapAmount(sats: Long): String = - when { - sats >= 1_000_000 -> "${sats / 1_000_000}M" - sats >= 1_000 -> "${sats / 1_000}k" - else -> sats.toString() - } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt index fdf6959cb2..a9252ccd93 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt @@ -40,11 +40,8 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -59,9 +56,11 @@ 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. @@ -78,67 +77,37 @@ fun RelatedContentSection( onViewAll: () -> Unit = {}, modifier: Modifier = Modifier, ) { - var relatedItems by remember(noteId) { mutableStateOf>(emptyList()) } + val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet() - DisposableEffect(noteId) { - val results = mutableListOf() - val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet() - val limit = 6 - - // Scan cache for related content - if (lowercaseTags.isNotEmpty()) { - localCache.notes.forEach { key, note -> - if (note.idHex != noteId && - note.event is TextNoteEvent && - note.event?.tags?.isTaggedHashes(lowercaseTags) == true - ) { - results.add(note) - } + // Re-scan the cache initially and whenever a bundle of new events arrives + // that contains a candidate (same hashtag or same author). Without this, + // expanding a note on a cold cache leaves the section empty until the user + // collapses + re-expands. + val relatedItems by produceState>( + initialValue = emptyList(), + key1 = noteId, + key2 = authorPubKey, + key3 = lowercaseTags, + ) { + fun rescan() { + runCatching { + value = scanRelated(localCache, noteId, authorPubKey, lowercaseTags) } } - - // Fallback: same author - if (results.size < limit) { - localCache.notes.forEach { key, note -> - if (note.idHex != noteId && - note.event is TextNoteEvent && - note.event?.pubKey == authorPubKey && - note !in results - ) { - results.add(note) + 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() } - - relatedItems = - results - .sortedByDescending { it.createdAt() } - .take(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 > java.math.BigDecimal.ZERO) "${note.zapsAmount.toLong()}" else "", - ) - } - - onDispose { } } if (relatedItems.isNotEmpty()) { @@ -192,6 +161,76 @@ fun RelatedContentSection( } } +private const val RELATED_LIMIT = 6 + +/** + * Scan the local cache for notes related to [noteId] either by sharing a + * hashtag in [lowercaseTags] or by being authored by [authorPubKey]. Returns + * up to [RELATED_LIMIT] notes, most recent first, mapped to [CompactNoteData]. + * + * Runs O(N) over `localCache.notes` — backed by `ConcurrentSkipListMap` which + * supports concurrent inserts during iteration (weakly consistent). Safe on + * the main composition coroutine for typical cache sizes (~30k notes). + */ +private fun scanRelated( + localCache: DesktopLocalCache, + noteId: String, + authorPubKey: String, + lowercaseTags: Set, +): List { + val results = mutableListOf() + + if (lowercaseTags.isNotEmpty()) { + localCache.notes.forEach { _, note -> + if (note.idHex != noteId && + note.event is TextNoteEvent && + note.event?.tags?.isTaggedHashes(lowercaseTags) == true + ) { + results.add(note) + } + } + } + + if (results.size < RELATED_LIMIT) { + localCache.notes.forEach { _, note -> + if (note.idHex != noteId && + note.event is TextNoteEvent && + note.event?.pubKey == authorPubKey && + note !in results + ) { + results.add(note) + } + } + } + + return results + .sortedByDescending { it.createdAt() } + .take(RELATED_LIMIT) + .map { note -> + val event = note.event + val content = event?.content ?: "" + val firstLine = + content + .take(80) + .lineSequence() + .firstOrNull() + ?.take(60) ?: "" + val author = localCache.getUserIfExists(event?.pubKey ?: "") + val imageUrl = + UrlParser() + .parseValidUrls(content) + .withScheme + .firstOrNull { RichTextParser.isImageUrl(it) } + CompactNoteData( + id = note.idHex, + title = firstLine.ifBlank { "Note" }, + authorName = author?.toBestDisplayName() ?: event?.pubKey?.take(8) ?: "", + thumbnailUrl = imageUrl, + zapCount = if (note.zapsAmount > BigDecimal.ZERO) showAmount(note.zapsAmount) else "", + ) + } +} + @Composable private fun CompactRelatedCard( item: CompactNoteData, diff --git a/docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md b/docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md new file mode 100644 index 0000000000..4beeba620f --- /dev/null +++ b/docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md @@ -0,0 +1,588 @@ +--- +title: Fix 5 review findings on PR #3124 desktop feed UI refresh +type: fix +status: active +date: 2026-06-02 +pr: https://github.com/vitorpamplona/amethyst/pull/3124 +review_comment: https://github.com/vitorpamplona/amethyst/pull/3124#issuecomment-4599816576 +worktree: ../AmethystMultiplatform-feed-review +branch: fix/desktop-feed-ui-review (tracks origin/feat/desktop-feed-ui-refresh) +deepened: 2026-06-02 +--- + +# Fix 5 review findings on PR #3124 + +## Enhancement summary (2026-06-02 deepen-plan) + +Eight parallel agents resolved all 5 open questions and surfaced 3 plan revisions: + +**Open questions resolved:** +- **Q1 — kind-1 NIP-10 vs kind-1111 NIP-22:** kind-1 NIP-10 confirmed. Android's + `NotificationReplyReceiver` already routes by parent type + (`TextNoteEvent` → kind 1, others → kind 1111). Desktop feed loads kind 1 + only (`DesktopFeedFilters.kt:39`). Use kind 1 + `prepareETagsAsReplyTo`. +- **Q2 — extract consume+broadcast couplet:** YES. Five clean call sites (no + inline complexity) + ordering inconsistency (`TextNoteEvent` does + consume→broadcast at `ThreadScreen.kt:361` and `FeedScreen.kt:1564`, + Reaction/Follow do broadcast→consume at `ThreadScreen.kt:424`, + `FeedScreen.kt:426`/`:1617`). A `desktopApp` extension fixes both volume and + the ordering drift. Canonical order: consume→broadcast (local-first). +- **Q3 — Phase 4 produceState vs ViewModel:** produceState. `LargeCache.notes` + is a `ConcurrentSkipListMap` (`LargeCache.jvmAndroid.kt:27`) — weakly + consistent iterator, safe on main composition coroutine, 50–150ms for ~30k + notes. No debounce needed; candidate-filter pre-check blocks 80–90% of + bundles. `FeedViewModel.kt:54-59` precedent collects same stream without + debounce. +- **Q4 — NoteActions.kt:264 formatSats:** sats, safe to swap to + `amount.toZapAmount()`. Inputs are hardcoded preset amounts (line 111 + `ZAP_AMOUNTS = listOf(21L, 100L, ...)`) and `LnZapEvent.amount` which is + already sats (`LnZapEvent.kt:69`). +- **Q5 — WalletColumnScreen.kt:979 formatSats:** intentional. Wallet shows + precise balance with locale-aware grouping (`1,000,000`). Leave + add + `// intentional` comment to prevent future drift. + +**Plan revisions:** +- **Phase 1 path flattening:** move `ReplyActions` from + `commons/.../actions/nip10Notes/ReplyActions.kt` to flat + `commons/.../actions/ReplyActions.kt`. Sister actions (`FollowActions`, + `ZapActions`, `DmActions`, `SearchActions`) are all flat under `actions/`; + no `nipNN/` subpackage convention. (Architecture review) +- **Phase 5 simplification:** drop the explicit `requestFocus()` in the Esc + handler; the column never loses focus during pop (Esc was *received by* the + focused column). Just `LaunchedEffect(Unit) { requestFocus() }` + the + existing `.focusable()`. Add `key(column.id) { DeckColumnContainer(...) }` + wrap in `DeckLayout.kt:111` so `LaunchedEffect(Unit)` survives column + reordering. (Code-simplicity + focus-audit review) +- **Phase 2 promoted from "optional":** with 5 verified duplicates + order + inconsistency, extract `Account.dispatch(signed: Event)` as a + `desktopApp` extension. Canonical order: consume→broadcast. Not in + `commons` (relay manager + cache are desktop types). + +**Android follow-up (out of this PR):** 4 inlined `TextNoteEvent.build` sites +on Android (`ShortNotePostViewModel.kt:1037`, `VoiceReplyViewModel.kt:265`, +`NotificationReplyReceiver.kt:203`, `AmethystAppFunctions.kt:1051`) should +migrate to the new `ReplyActions.replyTo` in a follow-up PR. Tracked in +"Future work" below. + +## Overview + +Davotoula's review on PR #3124 (`feat/desktop-feed-ui-refresh`) flagged 5 issues +ranging from one **NIP-10 protocol bug** (inline reply emits a tag set other +clients can't thread) down to **consistency bugs** (zap totals bypass the shared +formatter). All confirmed by inspecting `origin/feat/desktop-feed-ui-refresh`. + +This plan groups the fixes so dependent ones land in a sequence that compiles at +each step, and routes the protocol/architectural fixes through existing shared +helpers (`TextNoteEvent.build(replyingTo=…)`, `ReactionAction`, `FollowAction`, +`ZapFormatter.showAmount`) rather than introducing new abstractions. + +## Findings (root-cause confirmed) + +### #3 — Inline reply emits lower-fidelity NIP-10 tag set [PROTOCOL BUG] + +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt:1554` + +**Current code:** +```kotlin +val template = TextNoteEvent.build(content) { + val etag = ETag(event.id) + etag.relay = null + etag.author = event.pubKey + eTag(etag) + pTag(PTag(event.pubKey, relayHint = null)) +} +``` + +**Root cause:** the call uses the **single-arg** `TextNoteEvent.build(note, initializer)` +overload at `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt:133` +and hand-rolls a minimal reply tag set. It emits a single unmarked `e`-tag and a +single `p`-tag. **No NIP-10 root marker. No carry of the parent's root-e-tag. +No carry of the parent's p-tag chain.** Replying to a note deep in a thread +produces an event with no `root` reference; conformant clients (Damus, Primal, +Coracle…) can't reconstruct the thread. + +**Fix:** switch to the **reply-aware overload** at `TextNoteEvent.kt:142`: + +```kotlin +fun build( + note: String, + replyingTo: EventHintBundle? = null, + forkingFrom: EventHintBundle? = null, + … +) = eventTemplate(KIND, note, createdAt) { + alt(shortedMessageForAlt(note)) + if (replyingTo != null || forkingFrom != null) { + markedETags(prepareETagsAsReplyTo(replyingTo, forkingFrom)) + } + initializer() +} +``` + +`prepareETagsAsReplyTo` (already in quartz) handles **root marker, reply marker, +parent root/p-tag carry** correctly. The inline path was bypassing it. + +### #4 — Reaction/follow/reply business logic in desktop composables + +**Files:** +- `FeedScreen.kt:1611` — reaction (likes from inline expansion) +- `FeedScreen.kt:423` — follow (follow pill from feed) +- `FeedScreen.kt:1554` — reply (inline reply input) + +**Current state (verified):** +- **Follow** already uses `FollowAction.follow(pubKeyHex, signer, currentList)` + (commons). The complaint is the surrounding `cache.consume → broadcast` + couplet inlined in the composable. +- **Reaction** already uses `ReactionAction.reactTo(EventHintBundle, "+", signer)` + (commons). Same couplet inlined. +- **Reply** does NOT use a shared builder (see #3). + +**CLAUDE.md rule (`commons/ARCHITECTURE.md:73-88`):** "actions package (CLI-safe): +Event builders for user actions (follow, zap…). The canonical entry point for +non-UI callers." + +**Fix:** introduce a new shared action that mirrors `FollowActions` for kind-1 +replies. The consume+broadcast couplet stays inline (2 lines, platform-specific +relay/cache wiring), but the *protocol-touching* build moves out: + +```kotlin +// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/nip10Notes/ReplyActions.kt +object ReplyActions { + suspend fun replyTo( + parent: EventHintBundle, + content: String, + signer: NostrSigner, + ): TextNoteEvent { + val template = TextNoteEvent.build(content, replyingTo = parent) + return signer.sign(template) + } +} +``` + +Desktop call site becomes: +```kotlin +val parentText = event as? TextNoteEvent ?: return@withContext +val signed = ReplyActions.replyTo(EventHintBundle(parentText, null), content, account.signer) +localCache.consume(signed, relay = null) +relayManager.broadcastToAll(signed) +``` + +This matches the shape already used for `FollowAction.follow` at `FeedScreen.kt:423` +and `ReactionAction.reactTo` at `NoteActions.kt:1393`. Drift between desktop and +Android paths is bounded to a 3-line couplet that won't grow. + +### #1 — Related content stale after one scan + +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/thread/RelatedContentRow.kt:83` + +**Current code:** +```kotlin +DisposableEffect(noteId) { + val results = mutableListOf() + // scan localCache.notes once + localCache.notes.forEach { … } + relatedItems = results.sortedByDescending { it.createdAt() }.take(6).map { … } + onDispose { } +} +``` + +**Root cause:** `DisposableEffect(noteId)` re-runs only on `noteId` change. The +scan reads `localCache.notes` (a `LargeCache`) at composition time; nothing +re-runs the scan as the cache fills. Expanding a note on a cold cache leaves +the section empty/partial until collapse+re-expand. Also missing keys: +`noteHashtags` and `authorPubKey` (cosmetic — caller stabilises these per noteId). + +**Fix:** observe the cache's change stream and re-scan on bundle arrivals. +`DesktopLocalCache` exposes `eventStream: DesktopCacheEventStream` with +`newEventBundles: SharedFlow>` (`DesktopLocalCache.kt:719-743`). + +Two options: + +**Option A (simpler, matches inline-section scale):** `produceState` keyed by +`(noteId, hashtagsHash, authorPubKey)` that collects `newEventBundles` and +re-runs the scan when relevant events land: + +```kotlin +val relatedItems by produceState>(emptyList(), noteId, authorPubKey, noteHashtags) { + fun rescan() { value = scanRelated(localCache, noteId, authorPubKey, noteHashtags) } + rescan() // initial + localCache.eventStream.newEventBundles.collect { bundle -> + if (bundle.any { isCandidate(it, noteHashtags, authorPubKey) }) rescan() + } +} +``` + +**Option B (matches FeedViewModel family):** new `RelatedContentViewModel` in +`commons/src/commonMain/.../viewmodels/related/`, taking a `FeedFilter` style +"by hashtag OR by author" predicate and exposing +`StateFlow>`. Heavier but consistent with the rest of the +feed system. + +**Recommendation:** **Option A** — the related-content row is a 6-item sidecar, +not a feed. A ViewModel adds wiring without solving an actual problem here. +Deepen-plan agent may overrule. + +### #2 — Deck columns steal focus from each other + +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:150` + +**Current code:** +```kotlin +val focusRequester = remember { FocusRequester() } +LaunchedEffect(currentOverlay) { focusRequester.requestFocus() } +``` + +**Root cause:** `LaunchedEffect(currentOverlay)` fires on the column's first +composition **and on every overlay change**. In a multi-column deck, when +column B opens an overlay, column B's effect grabs focus — yanking it out of +column A's inline reply input mid-typing. + +**Fix:** decouple "request focus once on initial composition" from "Escape +handler needs focus to be live." The Escape key path works as long as the +column owns focus when the user presses Escape — which it does after the +initial composition. Match the existing pattern at +`EditProfileScreen.kt:380` (`LaunchedEffect(Unit) { focusRequester.requestFocus() }`) +**and** scope the effect so only the column the user is interacting with +re-grabs focus when a nested overlay closes (i.e. on `popOverlay()`). + +Concretely: +1. Change `LaunchedEffect(currentOverlay)` → `LaunchedEffect(Unit)` for the + initial focus request. +2. When the user presses Escape and `navState.pop()` succeeds, explicitly call + `focusRequester.requestFocus()` in the key handler (intent-driven, not + composition-driven). + +This contains focus stealing to the column the user actually interacted with. + +### #5 — Zap totals bypass shared formatter + +**Files:** +- `RelatedContentRow.kt:137` — `zapCount = "${note.zapsAmount.toLong()}"` (raw, e.g. `"1500000"`) +- `CommentItem.kt:155` — `text = formatZapAmount(zapAmount)` calling local helper +- `CommentItem.kt:166-171` — private `formatZapAmount(sats: Long)` hand-rolled k/M + +**Shared formatter (`commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt`):** +- `fun showAmount(amount: BigDecimal?): String` — G/M/k suffixes, `""` for null/<0.01 +- `fun showAmountWithZero(amount: BigDecimal?): String` — same, `"0"` instead of `""` +- `fun Long.toZapAmount(): String` +- `fun Int.toZapAmount(): String` + +`Note.zapsAmount` is `BigDecimal` (`commons/src/commonMain/.../model/Note.kt:183`), +so use `showAmount(note.zapsAmount)` directly in `RelatedContentRow`. `CommentItem` +takes a `Long`, so use `zapAmount.toZapAmount()` and delete the local helper. + +**Also flagged (outside review but same root cause):** +- `NoteActions.kt:264` — local `formatSats(amount: Long)` with only `k` suffix. +- `WalletColumnScreen.kt:979` — local `formatSats` using `NumberFormat` (intentional? + wallet flows may want full sats — leave but document). + +Cover the two review-flagged sites + `NoteActions.kt:264`. Defer wallet. + +## Phased implementation plan + +Phases ordered so each compiles + tests cleanly without depending on later work. + +### Phase 1 — Shared `ReplyActions` (fixes #3, completes #4 reply) + +**Create:** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActions.kt` *(flat — matches `FollowActions.kt`, `ZapActions.kt`, `DmActions.kt`; no `nip10Notes/` subpackage)* + +```kotlin +object ReplyActions { + suspend fun replyTo( + parent: EventHintBundle, + content: String, + signer: NostrSigner, + ): TextNoteEvent { + val template = TextNoteEvent.build(content, replyingTo = parent) + return signer.sign(template) + } +} +``` + +Mirror `FollowActions.buildFollow` shape (`commons/.../actions/FollowActions.kt:69`). + +**Test:** `commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ReplyActionsTest.kt` +— assert the signed event has: +- A marked e-tag with `marker=reply` pointing at the parent id. +- A marked e-tag with `marker=root` (pointing at parent's root if parent had one, + else parent's id). +- All parent p-tags carried + parent's `pubKey` appended. + +Use `runTest { … }` from `kotlinx-coroutines-test`, in-test signer is +`NostrSignerInternal(KeyPair(privHex.hexToByteArray()))` — match `FollowActionsTest.kt:25-37`. + +**Edit:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt` (~ line 1554) — replace inline build with `ReplyActions.replyTo(EventHintBundle(parentText, null), content, account.signer)`. Guard parent kind with `event as? TextNoteEvent`; if not a kind-1, skip / log (replies to non-kind-1 from the feed inline path were never well-defined and are out of scope; matches Android's `NotificationReplyReceiver.kt:136-156` routing). + +**Acceptance:** +- [ ] `./gradlew :commons:jvmTest --tests "*ReplyActionsTest*"` green. +- [ ] Manual: reply to a deep-thread note from desktop, inspect the broadcast event in a relay log → has `e-tag root` + `e-tag reply` + carries all parent `p` tags. +- [ ] Reply renders in Damus/Primal under the correct thread. + +### Phase 2 — Extract reaction/follow/reply consume+broadcast couplet (Option B confirmed) + +Deepen-plan audit found **5 clean duplicate sites** with an ordering +inconsistency between them: + +| File:line | Signer | Order today | +|---|---|---| +| `ThreadScreen.kt:361` | inline `TextNoteEvent.build` reply | consume → broadcast | +| `ThreadScreen.kt:424` | `ReactionAction.reactTo` | broadcast → consume | +| `FeedScreen.kt:426` | `FollowAction.follow` | broadcast → consume | +| `FeedScreen.kt:1564` | inline `TextNoteEvent.build` reply | consume → broadcast | +| `FeedScreen.kt:1617` | `ReactionAction.reactTo` | broadcast → consume | + +No site has inline extra work (snackbars, retries) entangled with the couplet. + +**Create:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/EventDispatch.kt` + +```kotlin +/** + * Canonical local-first dispatch: write to the local cache before broadcasting + * so the UI reflects the user's action immediately, even if relay round-trips fail. + */ +suspend fun dispatch( + signed: Event, + localCache: DesktopLocalCache, + relayManager: RelayManager, +) { + localCache.consume(signed, relay = null) + relayManager.broadcastToAll(signed) +} +``` + +(Or, equivalent — as an extension on a small `DispatchContext` if the call +sites already have one. Keep in `desktopApp` because both `LocalCache` and +`RelayManager` are desktop-side types.) + +**Migrate all 5 sites** to call `dispatch(signed, localCache, relayManager)`. +Fixes the ordering drift (everyone goes local-first) and shrinks call sites +to one line. + +**Acceptance:** +- [ ] `grep -rn "broadcastToAll" desktopApp/` shows only the call inside `EventDispatch.kt` + any non-couplet uses. +- [ ] No remaining call sites do consume + broadcast inline (other than the helper). +- [ ] Reactions, follows, and replies all still round-trip correctly in a manual sanity test. + +### Phase 3 — ZapFormatter swap (fixes #5) + +**Edit:** `RelatedContentRow.kt:137` — +```kotlin +zapCount = if (note.zapsAmount > BigDecimal.ZERO) showAmount(note.zapsAmount) else "", +``` + +**Edit:** `CommentItem.kt:155, 166-171` — replace `formatZapAmount(zapAmount)` with +`zapAmount.toZapAmount()`. **Delete the private `formatZapAmount` fun at line 166-171.** + +**Edit:** `NoteActions.kt:264` — swap to `amount.toZapAmount()`. Verified +sats (not msats): inputs are `ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)` +at line 111 + `LnZapEvent.amount` which is sats per `LnZapEvent.kt:69`. +**Delete the private `formatSats` fun if no remaining references** (run +`grep -n formatSats desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt` after swap). + +**Skip:** `WalletColumnScreen.kt:979` `formatSats` is **intentional** — wallet +balance display uses locale-aware grouping (`NumberFormat.getNumberInstance().format`) +for full-precision sats. Out of review scope. Add a one-line `// intentional: wallet shows precise sats with locale grouping; not a ZapFormatter target` comment to prevent future drift. + +**Acceptance:** +- [ ] `./gradlew :desktopApp:compileKotlin` green. +- [ ] Visual: a note with 1.5M sats renders `1.5M` (or `1M`, matching commons + semantics), not `1500000`. + +### Phase 4 — Cache-aware Related (fixes #1) + +**Edit:** `RelatedContentRow.kt:83-145` — replace `DisposableEffect(noteId)` with +`produceState` (Option A) keyed on `(noteId, authorPubKey, noteHashtags)`: + +```kotlin +val relatedItems by produceState>( + initialValue = emptyList(), + key1 = noteId, key2 = authorPubKey, key3 = noteHashtags, +) { + val lowercaseTags = noteHashtags.map { it.lowercase() }.toSet() + fun rescan() { + runCatching { + value = scanRelated(localCache, noteId, authorPubKey, lowercaseTags) + }.onFailure { + // weakly-consistent iterator may rarely surface; skip this tick + } + } + rescan() + localCache.eventStream.newEventBundles.collect { bundle -> + val matters = bundle.any { n -> + n.event is TextNoteEvent && + n.idHex != noteId && + (n.event?.pubKey == authorPubKey || + n.event?.tags?.isTaggedHashes(lowercaseTags) == true) + } + if (matters) rescan() + } +} +``` + +Extract the existing scan body into a private top-level `scanRelated(...)` so +both the initial call and the bundle-driven re-run share it. + +**Safety notes (deepen-plan):** +- `LargeCache.notes` is `ConcurrentSkipListMap` (`LargeCache.jvmAndroid.kt:27`) + — weakly-consistent iterator, safe on main composition coroutine. +- Scan is O(N): ~50–150ms for ~30k notes; fine on main. +- No debounce: candidate-filter blocks 80–90% of bundles. Matches + `FeedViewModel.kt:54-59` precedent (collects same stream without debounce). +- If hot-loop observed in production, retroactively add `.debounce(150)` + (precedent: `SearchBarState.kt:87`, `BookmarkListState.kt`). + +**Acceptance:** +- [ ] Cold-cache repro: open a note in a fresh session, related section starts + empty; as kind-1 events stream in matching the hashtag or author, related + cards appear without collapse+re-expand. +- [ ] No re-render storm: rescan only fires when a bundle contains a candidate. + +### Phase 5 — Focus gating (fixes #2) + +**Edit:** `DeckColumnContainer.kt:147-152` — +```kotlin +LaunchedEffect(Unit) { focusRequester.requestFocus() } // once on column creation +``` + +That's the only effect change. **No explicit `requestFocus()` in the Escape +handler**: deepen-plan focus-audit verified the column never loses focus during +back-nav (Escape was received by the focused column → it still has focus after +`navState.pop()`). Adding it would be cargo-cult. + +**Also edit:** `DeckLayout.kt:111` — wrap the `forEachIndexed` body in a +`key(column.id) { DeckColumnContainer(...) }` so `LaunchedEffect(Unit)` +survives column reordering. Without `key()`, moving a column in the deck list +re-fires the effect for the wrong column instance. + +**Acceptance:** +- [ ] Two-column repro: in column A's inline reply text field, type characters + while column B opens/closes an overlay → column A keeps focus, no + characters lost. +- [ ] Escape still pops nested overlays in the focused column. +- [ ] Reorder a column in the deck (drag if supported, or remove+re-add) → + typing focus in unrelated columns is preserved. + +## System-Wide Impact + +### Interaction graph + +- **Reply path:** `InlineReplyInput.onSend(content)` → `ReplyActions.replyTo(...)` (commons) → `signer.sign` → `localCache.consume` (DesktopLocalCache) → `relayManager.broadcastToAll` (NostrClient WS pool) → relay round-trip → cache update → `eventStream.newEventBundles` → Phase-4 `produceState` re-scan → related section refresh. Phase 4 is downstream of Phase 1 only by happy coincidence (a reply might match its parent's hashtags) — no hard coupling. +- **Follow path:** unchanged, already routed through `FollowAction.follow`. +- **Reaction path:** unchanged, already routed through `ReactionAction.reactTo`. + +### Error propagation + +- `ReplyActions.replyTo` is `suspend` and propagates `signer.sign` failures (cancellation, signer rejection). Desktop call site already runs in `withContext(Dispatchers.IO)` — wrap in `try/catch` to surface a snackbar on signing failure (Android path does this in `CommentPostViewModel.sendPostSync`; desktop currently swallows). +- Phase 4 `produceState` collect runs in the column's coroutine scope; cancelled when composable leaves composition. Exceptions in `scanRelated` (e.g. ConcurrentModificationException on `LargeCache.forEach`) would crash the collector — wrap `rescan()` body in `runCatching` to skip on transient cache mutations. + +### State lifecycle risks + +- Phase 1: signed reply written to `localCache` before broadcast succeeds. If + the broadcast fails, the reply is visible locally but not on relays. + This matches existing behaviour for reaction/follow paths; no new risk. +- Phase 4: `produceState` collects an unbounded `SharedFlow`. If `newEventBundles` + emits at high rate (cold cache fill), `scanRelated` runs O(N) per bundle. + `LargeCache.notes` size for an active user is ~10k–50k notes; a full scan is + ~ms. Acceptable; if hot-loop observed, debounce via `collectLatest` + + `delay(150)`. + +### API surface parity + +- `ReplyActions` is JVM-only consumer today (desktop), but lives in + `commons/commonMain` so Android can adopt it (and should — `NewPostViewModel` + on Android currently inlines a similar `TextNoteEvent.build` call). Tracked as + follow-up, **not in this PR**. + +### Integration test scenarios + +1. **NIP-10 thread fidelity:** create note A → reply B to A → reply C to B from + desktop. Inspect C's tags: must contain `["e", A.id, "", "root"]` and + `["e", B.id, "", "reply"]` and `["p", A.pubKey]` + `["p", B.pubKey]`. +2. **Cold-cache related:** clear local DB, open a thread → Related row empty → + simulate incoming kind-1 events matching parent's hashtag → row populates + without user interaction. +3. **Multi-column focus:** open two columns side by side. Start typing in column + A's inline reply. Open a profile overlay in column B. Verify typed characters + stay in column A. +4. **Reply to non-kind-1:** open a thread whose root is a `LongFormContentEvent` + (kind 30023). Inline reply must either disable (preferred) or route through + `CommentEvent` (NIP-22) — open question below. +5. **Zap formatting:** seed a note with 1_500_000 sats zaps. Both `RelatedContentRow` + and `CommentItem` render `1.5M` (or `1M` per commons rules). + +## Acceptance criteria (rollup) + +### Functional + +- [ ] Inline-reply event from desktop, when broadcast, threads correctly in + ≥1 non-Amethyst client (Damus or Primal verified). +- [ ] Related section refreshes from cold cache without user interaction. +- [ ] Typing in column A's reply box doesn't lose focus when column B opens an + overlay. +- [ ] Zap totals render with k/M/G suffix in `RelatedContentRow` and + `CommentItem`. +- [ ] Existing inline reaction/follow continue to work (no regression). + +### Non-functional + +- [ ] No new `--no-verify` commits. +- [ ] `./gradlew spotlessApply` clean. +- [ ] `./gradlew test` green for `:commons:jvmTest` and `:quartz:jvmTest`. +- [ ] No new Kotlin warnings introduced. + +### Quality gates + +- [ ] `ReplyActionsTest` covers root-marker, reply-marker, p-tag carry. +- [ ] Hand-rolled `formatZapAmount` deleted (grep returns 0 in `desktopApp/`). + +## Dependencies & risks + +| Risk | Likelihood | Mitigation | +|---|---|---| +| `EventHintBundle` cast fails when parent is `CommentEvent` / `LongFormContentEvent` | Med | Guard with `as? TextNoteEvent`; skip + log if null. Open question covers full support. | +| `produceState` re-runs scan storm on cold cache fill | Low | Filter bundle for candidate match before rescan; debounce if observed. | +| `LargeCache.forEach` concurrent modification during rescan | Low | Wrap rescan body in `runCatching`. | +| Focus fix breaks ESC → back-nav inside a column | Low | Explicit `requestFocus()` in pop handler covers it; manual repro before push. | +| `ZapFormatter.showAmount` returns `""` for amount < 0.01 — different from current `"0"`-on-empty | Low | Use `showAmountWithZero` if `"0"` desired, else gate with `if (note.zapsAmount > ZERO)`. | + +## Resolved questions (deepen-plan) + +All Q1–Q5 resolved — see "Enhancement summary" at top for verdicts + evidence. + +## Future work (separate PR, not in this branch) + +- **Android kind-1 reply migration.** Four Android sites inline + `TextNoteEvent.build` and should migrate to the new `ReplyActions.replyTo` + (single source of truth across platforms): + - `amethyst/.../ShortNotePostViewModel.kt:1037` + - `amethyst/.../VoiceReplyViewModel.kt:265` + - `amethyst/.../NotificationReplyReceiver.kt:203` + - `amethyst/.../AmethystAppFunctions.kt:1051` +- **CLI `amy reply` verb.** `ReplyActions` lives in `commons/commonMain` and + is CLI-safe — a future Amy reply verb wires straight to it. +- **Wallet vs Zap formatter consolidation.** `WalletColumnScreen.kt:979` + intentionally diverges (locale-aware full-precision). Revisit if/when a + unified "amount display" component is built. + +## Sources & references + +### Internal references + +- Review comment: https://github.com/vitorpamplona/amethyst/pull/3124#issuecomment-4599816576 +- PR: https://github.com/vitorpamplona/amethyst/pull/3124 +- `commons/ARCHITECTURE.md:73-88` — actions package boundary +- `quartz/.../nip10Notes/TextNoteEvent.kt:142` — reply-aware build overload +- `quartz/.../nip10Notes/tags/MarkedETag.kt:44-60` — NIP-10 marker enum + tag-array +- `quartz/.../nip10Notes/tags/prepareETagsAsReplyTo.kt` — root/reply tag-carry helper +- `commons/.../actions/FollowActions.kt:69` — pattern to mirror for `ReplyActions` +- `commons/.../model/nip25Reactions/ReactionAction.kt:50` — sister action +- `commons/.../util/ZapFormatter.kt` — shared zap-amount formatter +- `desktopApp/.../cache/DesktopLocalCache.kt:719-743` — `eventStream.newEventBundles` +- `desktopApp/.../ui/EditProfileScreen.kt:380` — `LaunchedEffect(Unit)` focus pattern to mirror +- `amethyst/.../ui/note/nip22Comments/CommentPostViewModel.kt:128, 447-571` — Android reply path (NIP-22 reference, not directly reused) + +### CLAUDE.md conventions + +- "Check existing implementations first — most logic already exists" — confirmed: shared helpers exist; this is reuse, not new abstraction. +- "Pre-commit hooks run spotless — always `./gradlew spotlessApply` before commit" +- "Never use `--no-verify`" +- "Verify, Don't Guess" — root causes verified by reading code at each line cited.