diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt index dd640810e..6314ac026 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt @@ -24,8 +24,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.unit.Density import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.markdown.BasicMarkdown @@ -39,6 +41,7 @@ fun RenderMarkdown( content: String, onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, + fontScale: Float = 1.0f, ) { val astNode = remember(content) { @@ -57,7 +60,23 @@ fun RenderMarkdown( } } - CompositionLocalProvider(LocalUriHandler provides uriHandler) { + val currentDensity = LocalDensity.current + val scaledDensity = + remember(fontScale, currentDensity) { + if (fontScale == 1.0f) { + currentDensity + } else { + Density( + density = currentDensity.density * fontScale, + fontScale = currentDensity.fontScale, + ) + } + } + + CompositionLocalProvider( + LocalUriHandler provides uriHandler, + LocalDensity provides scaledDensity, + ) { RichText( modifier = modifier, style = RichTextStyle(), 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 815048931..0f036aa2e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -379,6 +379,7 @@ fun main() { Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) }) Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index a6bdbc908..da9f93413 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -104,7 +104,11 @@ fun createUserPostsSubscription( ): SubscriptionConfig = SubscriptionConfig( subId = generateSubId("posts-${pubKeyHex.take(8)}"), - filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)), + filters = + listOf( + FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit), + FilterBuilders.longFormFromAuthors(listOf(pubKeyHex), limit = 50), + ), relays = relays, onEvent = onEvent, onEose = onEose, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index b7220448f..654cac22c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -51,6 +52,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.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.compose.article.ArticleHeader import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents @@ -65,8 +73,17 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -96,9 +113,12 @@ fun ArticleReaderScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -113,6 +133,14 @@ fun ArticleReaderScreen( var article by remember(addressTag) { mutableStateOf(null) } var eoseReceived by remember(addressTag) { mutableStateOf(false) } + // Zoom level for article text + var zoomLevel by remember { mutableStateOf(1.0f) } + val focusRequester = + remember { + androidx.compose.ui.focus + .FocusRequester() + } + // Active ToC entry tracking (placeholder — no scroll-position-based tracking yet) var activeTocIndex by remember { mutableStateOf(null) } @@ -165,6 +193,120 @@ fun ArticleReaderScreen( ) } + // Interaction state + val articleEventId = article?.id + val eventIds = listOfNotNull(articleEventId) + + var zapReceipts by remember { mutableStateOf>(emptyList()) } + var reactionCount by remember { mutableStateOf(0) } + var replyCount by remember { mutableStateOf(0) } + var repostCount by remember { mutableStateOf(0) } + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Subscribe to zaps + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + createZapsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + if (zapReceipts.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + zapReceipts = zapReceipts + receipt + } + } + }, + ) + } + + // Subscribe to reactions + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val reactionIds = mutableSetOf() + createReactionsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent && reactionIds.add(event.id)) { + reactionCount = reactionIds.size + } + }, + ) + } + + // Subscribe to replies + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val replyIds = mutableSetOf() + createRepliesSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (replyIds.add(event.id)) { + replyCount = replyIds.size + } + }, + ) + } + + // Subscribe to reposts + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val repostIds = mutableSetOf() + createRepostsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent && repostIds.add(event.id)) { + repostCount = repostIds.size + } + }, + ) + } + + // Subscribe to bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "article-bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + bookmarkedEventIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Derived data from article val title = article?.title() ?: "Untitled" val content = article?.content ?: "" @@ -189,7 +331,43 @@ fun ArticleReaderScreen( val authorName = authorUser?.toBestDisplayName() val authorPicture = authorUser?.profilePicture() - Column(modifier = Modifier.fillMaxSize()) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.isMetaPressed) { + when (event.key) { + Key.Equals -> { + zoomLevel = (zoomLevel + 0.1f).coerceAtMost(2.0f) + true + } + + Key.Minus -> { + zoomLevel = (zoomLevel - 0.1f).coerceAtLeast(0.5f) + true + } + + Key.Zero -> { + zoomLevel = 1.0f + true + } + + else -> { + false + } + } + } else { + false + } + }, + ) { // Top bar: back + bookmark placeholder Row( modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), @@ -210,6 +388,14 @@ fun ArticleReaderScreen( style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, ) + if (zoomLevel != 1.0f) { + Spacer(Modifier.width(8.dp)) + Text( + "${(zoomLevel * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -298,6 +484,7 @@ fun ArticleReaderScreen( RenderMarkdown( content = content, onLinkClick = onLinkClick, + fontScale = zoomLevel, ) Spacer(Modifier.height(32.dp)) @@ -321,6 +508,39 @@ fun ArticleReaderScreen( HorizontalDivider(thickness = 1.dp) + // Reaction actions + val art = article + if (art != null && account != null) { + Spacer(Modifier.height(16.dp)) + NoteActionsRow( + event = art, + relayManager = relayManager, + localCache = localCache, + account = account, + onReplyClick = { onNavigateToThread(art.id) }, + onZapFeedback = onZapFeedback, + zapCount = zapReceipts.size, + zapAmountSats = zapReceipts.sumOf { it.amountSats }, + zapReceipts = zapReceipts, + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + nwcConnection = nwcConnection, + isBookmarked = articleEventId in bookmarkedEventIds, + bookmarkList = bookmarkList, + onBookmarkChanged = { newList -> + bookmarkList = newList + bookmarkedEventIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.height(48.dp)) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 6f69cb9d5..2d5aba286 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -68,6 +69,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscr import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -177,8 +179,11 @@ fun ReadsScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, onNavigateToProfile: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val scope = rememberCoroutineScope() @@ -363,12 +368,27 @@ fun ReadsScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(events, key = { it.id }) { event -> - LongFormCard( - event = event, - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onClick = { onNavigateToArticle(event.addressTag()) }, - ) + Column { + LongFormCard( + event = event, + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onClick = { onNavigateToArticle(event.addressTag()) }, + ) + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onNavigateToThread(event.id) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } + HorizontalDivider(thickness = 1.dp) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt index f7ef606dd..eeee4a29d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -53,6 +53,7 @@ private val COLUMN_OPTIONS = DeckColumnType.Messages, DeckColumnType.Search, DeckColumnType.Reads, + DeckColumnType.Drafts, DeckColumnType.Bookmarks, DeckColumnType.GlobalFeed, DeckColumnType.MyProfile, 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 79559bafc..52cdc7eb1 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 @@ -44,9 +44,9 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode -import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen @@ -237,8 +237,11 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + nwcConnection = nwcConnection, onNavigateToProfile = onNavigateToProfile, onNavigateToArticle = onNavigateToArticle, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, ) } 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 ea458daa2..92dd5e91b 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 @@ -79,6 +79,7 @@ private val navItems = listOf( NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), + NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"), NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), @@ -177,6 +178,7 @@ fun SinglePaneLayout( onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, ) if (currentOverlay != null) { Surface(