finish ThreadScreen
This commit is contained in:
+45
@@ -86,3 +86,48 @@ fun createContactListSubscription(
|
||||
onEvent = onEvent,
|
||||
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.LoginScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -110,6 +111,10 @@ sealed class DesktopScreen {
|
||||
val pubKeyHex: String,
|
||||
) : DesktopScreen()
|
||||
|
||||
data class Thread(
|
||||
val noteId: String,
|
||||
) : DesktopScreen()
|
||||
|
||||
object Settings : DesktopScreen()
|
||||
}
|
||||
|
||||
@@ -351,6 +356,9 @@ fun MainContent(
|
||||
onNavigateToProfile = { pubKeyHex ->
|
||||
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
|
||||
},
|
||||
onNavigateToThread = { noteId ->
|
||||
onScreenChange(DesktopScreen.Thread(noteId))
|
||||
},
|
||||
)
|
||||
DesktopScreen.Search -> SearchPlaceholder()
|
||||
DesktopScreen.Messages -> MessagesPlaceholder()
|
||||
@@ -377,6 +385,19 @@ fun MainContent(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -75,8 +76,14 @@ fun FeedNoteCard(
|
||||
account: AccountState.LoggedIn?,
|
||||
onReply: () -> Unit,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(),
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
@@ -101,6 +108,7 @@ fun FeedScreen(
|
||||
account: AccountState.LoggedIn? = null,
|
||||
onCompose: () -> Unit = {},
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
@@ -274,6 +282,7 @@ fun FeedScreen(
|
||||
account = account,
|
||||
onReply = { replyToEvent = event },
|
||||
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