finish ThreadScreen
This commit is contained in:
+45
@@ -86,3 +86,48 @@ fun createContactListSubscription(
|
|||||||
onEvent = onEvent,
|
onEvent = onEvent,
|
||||||
onEose = onEose,
|
onEose = onEose,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription config for fetching a specific note by ID.
|
||||||
|
*/
|
||||||
|
fun createNoteSubscription(
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
noteId: String,
|
||||||
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
): SubscriptionConfig =
|
||||||
|
SubscriptionConfig(
|
||||||
|
subId = generateSubId("note-${noteId.take(8)}"),
|
||||||
|
filters = listOf(FilterBuilders.byIds(listOf(noteId))),
|
||||||
|
relays = relays,
|
||||||
|
onEvent = onEvent,
|
||||||
|
onEose = onEose,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription config for fetching all replies to a note (thread).
|
||||||
|
*
|
||||||
|
* @param noteId The root note ID to fetch replies for
|
||||||
|
* @param limit Maximum number of reply events to request
|
||||||
|
*/
|
||||||
|
fun createThreadRepliesSubscription(
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
noteId: String,
|
||||||
|
limit: Int = 200,
|
||||||
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
): SubscriptionConfig =
|
||||||
|
SubscriptionConfig(
|
||||||
|
subId = generateSubId("thread-${noteId.take(8)}"),
|
||||||
|
filters =
|
||||||
|
listOf(
|
||||||
|
FilterBuilders.byETags(
|
||||||
|
eventIds = listOf(noteId),
|
||||||
|
kinds = listOf(1), // TextNoteEvent
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
relays = relays,
|
||||||
|
onEvent = onEvent,
|
||||||
|
onEose = onEose,
|
||||||
|
)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
|||||||
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
|
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -110,6 +111,10 @@ sealed class DesktopScreen {
|
|||||||
val pubKeyHex: String,
|
val pubKeyHex: String,
|
||||||
) : DesktopScreen()
|
) : DesktopScreen()
|
||||||
|
|
||||||
|
data class Thread(
|
||||||
|
val noteId: String,
|
||||||
|
) : DesktopScreen()
|
||||||
|
|
||||||
object Settings : DesktopScreen()
|
object Settings : DesktopScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +356,9 @@ fun MainContent(
|
|||||||
onNavigateToProfile = { pubKeyHex ->
|
onNavigateToProfile = { pubKeyHex ->
|
||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||||
},
|
},
|
||||||
|
onNavigateToThread = { noteId ->
|
||||||
|
onScreenChange(DesktopScreen.Thread(noteId))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
DesktopScreen.Search -> SearchPlaceholder()
|
DesktopScreen.Search -> SearchPlaceholder()
|
||||||
DesktopScreen.Messages -> MessagesPlaceholder()
|
DesktopScreen.Messages -> MessagesPlaceholder()
|
||||||
@@ -377,6 +385,19 @@ fun MainContent(
|
|||||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
is DesktopScreen.Thread ->
|
||||||
|
ThreadScreen(
|
||||||
|
noteId = currentScreen.noteId,
|
||||||
|
relayManager = relayManager,
|
||||||
|
account = account,
|
||||||
|
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||||
|
onNavigateToProfile = { pubKeyHex ->
|
||||||
|
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||||
|
},
|
||||||
|
onNavigateToThread = { noteId ->
|
||||||
|
onScreenChange(DesktopScreen.Thread(noteId))
|
||||||
|
},
|
||||||
|
)
|
||||||
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account)
|
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -75,8 +76,14 @@ fun FeedNoteCard(
|
|||||||
account: AccountState.LoggedIn?,
|
account: AccountState.LoggedIn?,
|
||||||
onReply: () -> Unit,
|
onReply: () -> Unit,
|
||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
onNavigateToThread(event.id)
|
||||||
|
},
|
||||||
|
) {
|
||||||
NoteCard(
|
NoteCard(
|
||||||
note = event.toNoteDisplayData(),
|
note = event.toNoteDisplayData(),
|
||||||
onAuthorClick = onNavigateToProfile,
|
onAuthorClick = onNavigateToProfile,
|
||||||
@@ -101,6 +108,7 @@ fun FeedScreen(
|
|||||||
account: AccountState.LoggedIn? = null,
|
account: AccountState.LoggedIn? = null,
|
||||||
onCompose: () -> Unit = {},
|
onCompose: () -> Unit = {},
|
||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
@@ -274,6 +282,7 @@ fun FeedScreen(
|
|||||||
account = account,
|
account = account,
|
||||||
onReply = { replyToEvent = event },
|
onReply = { replyToEvent = event },
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop Thread Screen - displays a note and all its replies in a thread view.
|
||||||
|
*
|
||||||
|
* Uses the shared drawReplyLevel modifier from commons to display reply nesting.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ThreadScreen(
|
||||||
|
noteId: String,
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
account: AccountState.LoggedIn?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// State for the root note
|
||||||
|
var rootNote by remember { mutableStateOf<Event?>(null) }
|
||||||
|
|
||||||
|
// State for reply events
|
||||||
|
val replyEventState =
|
||||||
|
remember(noteId) {
|
||||||
|
EventCollectionState<Event>(
|
||||||
|
getId = { it.id },
|
||||||
|
sortComparator = compareBy { it.createdAt },
|
||||||
|
maxSize = 500,
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val replyEvents by replyEventState.items.collectAsState()
|
||||||
|
|
||||||
|
// Cache for calculating reply levels
|
||||||
|
val levelCache = remember(noteId) { mutableMapOf<String, Int>() }
|
||||||
|
|
||||||
|
// Subscribe to the root note
|
||||||
|
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
||||||
|
val configuredRelays = relayStatuses.keys
|
||||||
|
if (configuredRelays.isNotEmpty()) {
|
||||||
|
createNoteSubscription(
|
||||||
|
relays = configuredRelays,
|
||||||
|
noteId = noteId,
|
||||||
|
onEvent = { event, _, _, _ ->
|
||||||
|
if (event.id == noteId) {
|
||||||
|
rootNote = event
|
||||||
|
levelCache[event.id] = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to replies
|
||||||
|
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
||||||
|
val configuredRelays = relayStatuses.keys
|
||||||
|
if (configuredRelays.isNotEmpty()) {
|
||||||
|
createThreadRepliesSubscription(
|
||||||
|
relays = configuredRelays,
|
||||||
|
noteId = noteId,
|
||||||
|
onEvent = { event, _, _, _ ->
|
||||||
|
replyEventState.addItem(event)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate reply level for an event based on e-tags
|
||||||
|
fun calculateLevel(event: Event): Int {
|
||||||
|
levelCache[event.id]?.let { return it }
|
||||||
|
|
||||||
|
// Find the event this is replying to (last e-tag or marked reply/root)
|
||||||
|
val replyToId = findReplyToId(event)
|
||||||
|
val level =
|
||||||
|
if (replyToId == null || replyToId == noteId) {
|
||||||
|
1 // Direct reply to root
|
||||||
|
} else {
|
||||||
|
(levelCache[replyToId] ?: 0) + 1
|
||||||
|
}
|
||||||
|
levelCache[event.id] = level
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Header with back button
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Thread",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectedRelays.isEmpty()) {
|
||||||
|
LoadingState("Connecting to relays...")
|
||||||
|
} else if (rootNote == null) {
|
||||||
|
LoadingState("Loading thread...")
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
) {
|
||||||
|
// Root note (no reply level indicator)
|
||||||
|
item(key = noteId) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
// Already viewing this thread, no-op
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
NoteCard(
|
||||||
|
note = rootNote!!.toNoteDisplayData(),
|
||||||
|
onAuthorClick = onNavigateToProfile,
|
||||||
|
)
|
||||||
|
if (account != null) {
|
||||||
|
NoteActionsRow(
|
||||||
|
event = rootNote!!,
|
||||||
|
relayManager = relayManager,
|
||||||
|
account = account,
|
||||||
|
onReplyClick = { /* TODO: Open reply dialog */ },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(thickness = 1.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply notes with level indicators
|
||||||
|
items(replyEvents, key = { it.id }) { event ->
|
||||||
|
val level = calculateLevel(event)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.drawReplyLevel(
|
||||||
|
level = level,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
selected =
|
||||||
|
if (event.id == noteId) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
},
|
||||||
|
).clickable {
|
||||||
|
onNavigateToThread(event.id)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
NoteCard(
|
||||||
|
note = event.toNoteDisplayData(),
|
||||||
|
onAuthorClick = onNavigateToProfile,
|
||||||
|
)
|
||||||
|
if (account != null) {
|
||||||
|
NoteActionsRow(
|
||||||
|
event = event,
|
||||||
|
relayManager = relayManager,
|
||||||
|
account = account,
|
||||||
|
onReplyClick = { /* TODO: Open reply dialog */ },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(thickness = 1.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state for no replies
|
||||||
|
if (replyEvents.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
"No replies yet",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the event ID this event is replying to.
|
||||||
|
* Uses NIP-10 markers (reply/root) or falls back to last e-tag.
|
||||||
|
*/
|
||||||
|
private fun findReplyToId(event: Event): String? {
|
||||||
|
val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" }
|
||||||
|
if (eTags.isEmpty()) return null
|
||||||
|
|
||||||
|
// Check for NIP-10 marked tags first
|
||||||
|
val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" }
|
||||||
|
if (replyTag != null) return replyTag[1]
|
||||||
|
|
||||||
|
val rootTag = eTags.find { it.size >= 4 && it[3] == "root" }
|
||||||
|
if (rootTag != null && eTags.size == 1) return rootTag[1]
|
||||||
|
|
||||||
|
// Fall back to positional (last e-tag is the reply-to)
|
||||||
|
return eTags.lastOrNull()?.get(1)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user