initial desktop phase1

This commit is contained in:
nrobi144
2026-01-02 07:51:31 +02:00
parent 5c4db83b40
commit 146e4e5533
9 changed files with 1748 additions and 64 deletions
@@ -99,6 +99,25 @@ open class RelayConnectionManager(
client.send(event, relays)
}
/**
* Broadcasts an event to all configured relays.
* Events will be sent to relays as they become connected.
*/
fun broadcastToAll(event: Event) {
// Use all configured relays, not just currently connected ones
// The NostrClient will queue/send events as relays connect
val configuredRelays = relayStatuses.value.keys
val connectedCount = connectedRelays.value.size
println("[RelayManager] broadcastToAll: event kind=${event.kind}, ID=${event.id.take(8)}...")
println("[RelayManager] Configured relays: ${configuredRelays.size}, Connected: $connectedCount")
configuredRelays.forEach { relay ->
val isConnected = connectedRelays.value.contains(relay)
println("[RelayManager] - $relay: ${if (isConnected) "CONNECTED" else "pending"}")
}
send(event, configuredRelays)
println("[RelayManager] broadcastToAll complete")
}
private fun updateRelayStatus(
url: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -50,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.util.toTimeAgo
*/
data class NoteDisplayData(
val id: String,
val pubKeyHex: String,
val pubKeyDisplay: String,
val content: String,
val createdAt: Long,
@@ -64,6 +66,7 @@ fun NoteCard(
note: NoteDisplayData,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null,
) {
val richTextParser = remember { RichTextParser() }
val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) }
@@ -82,12 +85,18 @@ fun NoteCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Author (truncated)
// Author (truncated, clickable)
Text(
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier =
if (onAuthorClick != null) {
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
} else {
Modifier
},
)
// Timestamp
@@ -38,6 +38,7 @@ fun Event.toNoteDisplayData(): NoteDisplayData {
return NoteDisplayData(
id = id,
pubKeyHex = pubKey,
pubKeyDisplay = npub,
content = content,
createdAt = createdAt,
@@ -81,8 +81,32 @@ import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
import com.vitorpamplona.amethyst.commons.ui.screens.NotificationsPlaceholder
import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
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.UserProfileScreen
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
/**
* Desktop navigation state - extends AppScreen with dynamic destinations.
*/
sealed class DesktopScreen {
data object Feed : DesktopScreen()
data object Search : DesktopScreen()
data object Messages : DesktopScreen()
data object Notifications : DesktopScreen()
data object MyProfile : DesktopScreen()
data class UserProfile(val pubKeyHex: String) : DesktopScreen()
data object Settings : DesktopScreen()
}
fun main() =
application {
@@ -92,6 +116,7 @@ fun main() =
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center),
)
var showComposeDialog by remember { mutableStateOf(false) }
Window(
onCloseRequest = ::exitApplication,
@@ -102,25 +127,58 @@ fun main() =
Menu("File") {
Item(
"New Note",
shortcut = KeyShortcut(Key.N, ctrl = true),
onClick = { /* TODO: Open new note dialog */ },
shortcut =
if (isMacOS) {
KeyShortcut(Key.N, meta = true)
} else {
KeyShortcut(Key.N, ctrl = true)
},
onClick = { showComposeDialog = true },
)
Separator()
Item(
"Settings",
shortcut = KeyShortcut(Key.Comma, ctrl = true),
shortcut =
if (isMacOS) {
KeyShortcut(Key.Comma, meta = true)
} else {
KeyShortcut(Key.Comma, ctrl = true)
},
onClick = { /* TODO: Open settings */ },
)
Separator()
Item(
"Quit",
shortcut = KeyShortcut(Key.Q, ctrl = true),
shortcut =
if (isMacOS) {
KeyShortcut(Key.Q, meta = true)
} else {
KeyShortcut(Key.Q, ctrl = true)
},
onClick = ::exitApplication,
)
}
Menu("Edit") {
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { })
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { })
Item(
"Copy",
shortcut =
if (isMacOS) {
KeyShortcut(Key.C, meta = true)
} else {
KeyShortcut(Key.C, ctrl = true)
},
onClick = { },
)
Item(
"Paste",
shortcut =
if (isMacOS) {
KeyShortcut(Key.V, meta = true)
} else {
KeyShortcut(Key.V, ctrl = true)
},
onClick = { },
)
}
Menu("View") {
Item("Feed", onClick = { })
@@ -133,13 +191,21 @@ fun main() =
}
}
App()
App(
showComposeDialog = showComposeDialog,
onShowComposeDialog = { showComposeDialog = true },
onDismissComposeDialog = { showComposeDialog = false },
)
}
}
@Composable
fun App() {
var currentScreen by remember { mutableStateOf(AppScreen.Feed) }
fun App(
showComposeDialog: Boolean,
onShowComposeDialog: () -> Unit,
onDismissComposeDialog: () -> Unit,
) {
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) }
val relayManager = remember { DesktopRelayConnectionManager() }
val accountManager = remember { AccountManager() }
val accountState by accountManager.accountState.collectAsState()
@@ -163,17 +229,29 @@ fun App() {
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
onLoginSuccess = { currentScreen = AppScreen.Feed },
onLoginSuccess = { currentScreen = DesktopScreen.Feed },
)
}
is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn
MainContent(
currentScreen = currentScreen,
onScreenChange = { currentScreen = it },
relayManager = relayManager,
accountManager = accountManager,
account = accountState as AccountState.LoggedIn,
account = account,
onShowComposeDialog = onShowComposeDialog,
)
// Compose dialog
if (showComposeDialog) {
ComposeNoteDialog(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
)
}
}
}
}
@@ -182,11 +260,12 @@ fun App() {
@Composable
fun MainContent(
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit,
currentScreen: DesktopScreen,
onScreenChange: (DesktopScreen) -> Unit,
relayManager: DesktopRelayConnectionManager,
accountManager: AccountManager,
account: AccountState.LoggedIn,
onShowComposeDialog: () -> Unit,
) {
Row(Modifier.fillMaxSize()) {
// Sidebar Navigation
@@ -199,36 +278,36 @@ fun MainContent(
NavigationRailItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) },
selected = currentScreen == DesktopScreen.Feed,
onClick = { onScreenChange(DesktopScreen.Feed) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
label = { Text("Search") },
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) },
selected = currentScreen == DesktopScreen.Search,
onClick = { onScreenChange(DesktopScreen.Search) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
label = { Text("DMs") },
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) },
selected = currentScreen == DesktopScreen.Messages,
onClick = { onScreenChange(DesktopScreen.Messages) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == AppScreen.Notifications,
onClick = { onScreenChange(AppScreen.Notifications) },
selected = currentScreen == DesktopScreen.Notifications,
onClick = { onScreenChange(DesktopScreen.Notifications) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
label = { Text("Profile") },
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) },
selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
onClick = { onScreenChange(DesktopScreen.MyProfile) },
)
Spacer(Modifier.weight(1f))
@@ -238,8 +317,8 @@ fun MainContent(
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = currentScreen == AppScreen.Settings,
onClick = { onScreenChange(AppScreen.Settings) },
selected = currentScreen == DesktopScreen.Settings,
onClick = { onScreenChange(DesktopScreen.Settings) },
)
Spacer(Modifier.height(16.dp))
@@ -252,12 +331,41 @@ fun MainContent(
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen(relayManager)
AppScreen.Search -> SearchPlaceholder()
AppScreen.Messages -> MessagesPlaceholder()
AppScreen.Notifications -> NotificationsPlaceholder()
AppScreen.Profile -> ProfileScreen(account, accountManager)
AppScreen.Settings -> RelaySettingsScreen(relayManager)
DesktopScreen.Feed ->
FeedScreen(
relayManager = relayManager,
account = account,
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
)
DesktopScreen.Search -> SearchPlaceholder()
DesktopScreen.Messages -> MessagesPlaceholder()
DesktopScreen.Notifications -> NotificationsScreen(relayManager, account)
DesktopScreen.MyProfile ->
UserProfileScreen(
pubKeyHex = account.pubKeyHex,
relayManager = relayManager,
account = account,
onBack = { onScreenChange(DesktopScreen.Feed) },
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
)
is DesktopScreen.UserProfile ->
UserProfileScreen(
pubKeyHex = currentScreen.pubKeyHex,
relayManager = relayManager,
account = account,
onBack = { onScreenChange(DesktopScreen.Feed) },
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
)
DesktopScreen.Settings -> RelaySettingsScreen(relayManager)
}
}
}
@@ -0,0 +1,227 @@
/**
* 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.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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
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.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ComposeNoteDialog(
onDismiss: () -> Unit,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null,
) {
var content by remember { mutableStateOf("") }
var isPosting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) {
Card(
modifier = Modifier.width(600.dp).padding(16.dp),
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
if (replyTo != null) "Reply" else "New Note",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
replyTo?.let { reply ->
Spacer(Modifier.height(8.dp))
Text(
"Replying to: ${reply.content.take(50)}${if (reply.content.length > 50) "..." else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = content,
onValueChange = {
content = it
errorMessage = null
},
modifier = Modifier.fillMaxWidth().height(200.dp),
label = { Text("What's on your mind?") },
placeholder = { Text("Write your note...") },
enabled = !isPosting,
maxLines = 10,
)
Spacer(Modifier.height(8.dp))
// Character count
Text(
"${content.length} characters",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
errorMessage?.let { error ->
Spacer(Modifier.height(8.dp))
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = onDismiss,
enabled = !isPosting,
) {
Text("Cancel")
}
Spacer(Modifier.width(8.dp))
Button(
onClick = {
if (content.isBlank()) {
errorMessage = "Note cannot be empty"
return@Button
}
scope.launch {
isPosting = true
errorMessage = null
try {
publishNote(
content = content,
account = account,
relayManager = relayManager,
replyTo = replyTo,
)
onDismiss()
} catch (e: Exception) {
errorMessage = "Failed to publish: ${e.message}"
} finally {
isPosting = false
}
}
},
enabled = !isPosting && content.isNotBlank(),
) {
Text(if (isPosting) "Publishing..." else "Publish")
}
}
}
}
}
}
/**
* Publishes a text note to relays.
* Uses the Account's key to sign the event.
*/
private suspend fun publishNote(
content: String,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
) {
withContext(Dispatchers.IO) {
println("[ComposeNoteDialog] Starting publishNote: content length=${content.length}")
// Build TextNoteEvent using Quartz
val template =
TextNoteEvent.build(content) {
// If replying, add e-tag and p-tag
if (replyTo != null) {
val etag = ETag(replyTo.id)
etag.relay = null
etag.author = replyTo.pubKey
eTag(etag)
pTag(PTag(replyTo.pubKey, relayHint = null))
}
// Extract hashtags and URLs from content
hashtags(findHashtags(content))
references(findURLs(content))
}
// Sign the event
val signedEvent =
if (account.isReadOnly) {
// Read-only mode - can't sign events
println("[ComposeNoteDialog] Error: Cannot post in read-only mode")
throw IllegalStateException("Cannot post in read-only mode")
} else {
// Sign with nsec (full key)
val signer = account.signer
println("[ComposeNoteDialog] Signing event with pubkey: ${account.pubKeyHex.take(8)}...")
signer.sign(template)
}
println("[ComposeNoteDialog] Event signed successfully, ID: ${signedEvent.id.take(8)}...")
// Broadcast to all configured relays
println("[ComposeNoteDialog] Broadcasting to relays...")
relayManager.broadcastToAll(signedEvent)
println("[ComposeNoteDialog] Broadcast complete")
}
}
@@ -22,19 +22,37 @@ package com.vitorpamplona.amethyst.desktop.ui
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.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
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.DisposableEffect
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.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.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
@@ -44,35 +62,141 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
/**
* Note card with action buttons.
*/
@Composable
fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
fun FeedNoteCard(
event: Event,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn?,
onReply: () -> Unit,
onNavigateToProfile: (String) -> Unit = {},
) {
Column {
NoteCard(
note = event.toNoteDisplayData(),
onAuthorClick = onNavigateToProfile,
)
// Action buttons (only if logged in)
if (account != null) {
NoteActionsRow(
event = event,
relayManager = relayManager,
account = account,
onReplyClick = onReply,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
}
enum class FeedMode {
GLOBAL,
FOLLOWING,
}
@Composable
fun FeedScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn? = null,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val events = remember { mutableStateListOf<Event>() }
val seenIds = remember { mutableSetOf<String>() }
var replyToEvent by remember { mutableStateOf<Event?>(null) }
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
// Use relayStatuses.keys (configured relays) to initiate subscription,
// not connectedRelays (which is empty until subscriptions trigger connections)
val configuredRelays = relayStatuses.keys
DisposableEffect(configuredRelays) {
if (configuredRelays.isNotEmpty()) {
val subId = "global-feed-${System.currentTimeMillis()}"
val filters =
listOf(
Filter(
kinds = listOf(TextNoteEvent.KIND),
limit = 50,
),
)
// Load followed users for Following feed mode
DisposableEffect(relayStatuses, account, feedMode) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
val contactListSubId = "feed-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}"
relayManager.subscribe(
subId = subId,
filters = filters,
subId = contactListSubId,
filters =
listOf(
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.pubKeyHex),
limit = 1,
),
),
relays = configuredRelays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is ContactListEvent) {
followedUsers = event.verifiedFollowKeySet()
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
},
)
onDispose {
relayManager.unsubscribe(contactListSubId)
}
} else {
onDispose {}
}
}
DisposableEffect(relayStatuses, feedMode, followedUsers) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
// Clear previous events when switching modes
events.clear()
seenIds.clear()
val subId = "${feedMode.name.lowercase()}-feed-${System.currentTimeMillis()}"
val filters =
when (feedMode) {
FeedMode.GLOBAL ->
listOf(
Filter(
kinds = listOf(TextNoteEvent.KIND),
limit = 50,
),
)
FeedMode.FOLLOWING ->
if (followedUsers.isNotEmpty()) {
listOf(
Filter(
kinds = listOf(TextNoteEvent.KIND),
authors = followedUsers.toList(),
limit = 50,
),
)
} else {
// No followed users yet, return empty filter
emptyList()
}
}
if (filters.isNotEmpty()) {
relayManager.subscribe(
subId = subId,
filters = filters,
relays = configuredRelays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
@@ -97,40 +221,132 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
// End of stored events
}
},
)
)
onDispose {
relayManager.unsubscribe(subId)
onDispose {
relayManager.unsubscribe(subId)
}
} else {
onDispose {}
}
} else {
onDispose { }
onDispose {}
}
}
Column(modifier = Modifier.fillMaxSize()) {
FeedHeader(
title = "Global Feed",
connectedRelayCount = connectedRelays.size,
onRefresh = { relayManager.connect() },
)
// Header with compose button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
// Feed mode selector
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL },
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { feedMode = FeedMode.FOLLOWING },
label = { Text("Following") },
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${connectedRelays.size} relays connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
"${followedUsers.size} followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { relayManager.connect() },
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
// New Post button (primary action)
Button(
onClick = onCompose,
enabled = account != null && !account.isReadOnly,
) {
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("New Post")
}
}
Spacer(Modifier.height(8.dp))
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
LoadingState("Loading followed users...")
} else if (events.isEmpty()) {
LoadingState("Loading notes...")
LoadingState(
if (feedMode == FeedMode.FOLLOWING) {
"No notes from followed users yet"
} else {
"Loading notes..."
},
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(events, key = { it.id }) { event ->
// Use NoteCard from commons
NoteCard(
note = event.toNoteDisplayData(),
FeedNoteCard(
event = event,
relayManager = relayManager,
account = account,
onReply = { replyToEvent = event },
onNavigateToProfile = onNavigateToProfile,
)
}
}
}
// Reply dialog
if (replyToEvent != null && account != null) {
ComposeNoteDialog(
onDismiss = { replyToEvent = null },
relayManager = relayManager,
account = account,
replyTo = replyToEvent,
)
}
}
}
@@ -0,0 +1,212 @@
/**
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
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.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.icons.Reply
import com.vitorpamplona.amethyst.commons.icons.Repost
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Action buttons row for a note (react, reply, repost).
*/
@Composable
fun NoteActionsRow(
event: Event,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
onReplyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var isLiked by remember { mutableStateOf(false) }
var isReposted by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Reply button
IconButton(
onClick = onReplyClick,
modifier = Modifier.size(32.dp),
) {
Icon(
Reply,
contentDescription = "Reply",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
// Like button
IconButton(
onClick = {
if (!isLiked) {
scope.launch {
reactToNote(
event = event,
reaction = "+",
account = account,
relayManager = relayManager,
)
isLiked = true
}
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (isLiked) "Unlike" else "Like",
tint =
if (isLiked) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(18.dp),
)
}
// Repost button
IconButton(
onClick = {
if (!isReposted) {
scope.launch {
repostNote(
event = event,
account = account,
relayManager = relayManager,
)
isReposted = true
}
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
Repost,
contentDescription = "Repost",
tint =
if (isReposted) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(18.dp),
)
}
// Placeholder for action count
Text(
"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* Creates a reaction event and broadcasts to relays.
*/
private suspend fun reactToNote(
event: Event,
reaction: String,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
) {
withContext(Dispatchers.IO) {
// Build reaction event
val reactedTo = EventHintBundle(event, null)
val template = ReactionEvent.like(reactedTo)
// Sign with user's key
val signedEvent = account.signer.sign(template)
// Broadcast to all relays
relayManager.broadcastToAll(signedEvent)
}
}
/**
* Creates a repost event and broadcasts to relays.
*/
private suspend fun repostNote(
event: Event,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
) {
withContext(Dispatchers.IO) {
// Build repost event
val template =
if (event.kind == 1) {
// Text note - use RepostEvent (kind 6)
RepostEvent.build(
boostedPost = event,
eventSourceRelay = null,
authorHomeRelay = null,
)
} else {
// Other kinds - use GenericRepostEvent (kind 16)
GenericRepostEvent.build(
boostedPost = event,
eventSourceRelay = null,
authorHomeRelay = null,
)
}
// Sign with user's key
val signedEvent = account.signer.sign(template)
// Broadcast to all relays
relayManager.broadcastToAll(signedEvent)
}
}
@@ -0,0 +1,305 @@
/**
* 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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
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.icons.Reply
import com.vitorpamplona.amethyst.commons.icons.Repost
import com.vitorpamplona.amethyst.commons.icons.Zap
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/**
* Notification types for display.
*/
sealed class NotificationItem(
open val event: Event,
open val timestamp: Long,
) {
data class Mention(override val event: Event, override val timestamp: Long) : NotificationItem(event, timestamp)
data class Reply(override val event: Event, override val timestamp: Long) : NotificationItem(event, timestamp)
data class Reaction(override val event: Event, override val timestamp: Long, val content: String) :
NotificationItem(event, timestamp)
data class Repost(override val event: Event, override val timestamp: Long) : NotificationItem(event, timestamp)
data class Zap(override val event: Event, override val timestamp: Long, val amount: Long?) :
NotificationItem(event, timestamp)
}
@Composable
fun NotificationsScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val notifications = remember { mutableStateListOf<NotificationItem>() }
val seenIds = remember { mutableSetOf<String>() }
DisposableEffect(relayStatuses, account.pubKeyHex) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
val subId = "notifications-${account.pubKeyHex}-${System.currentTimeMillis()}"
val filters =
listOf(
// Mentions, replies, reactions, reposts, zaps
Filter(
kinds =
listOf(
TextNoteEvent.KIND, // 1 - mentions/replies
ReactionEvent.KIND, // 7 - reactions
RepostEvent.KIND, // 6 - reposts
GenericRepostEvent.KIND, // 16 - generic reposts
LnZapEvent.KIND, // 9735 - zaps
),
tags = mapOf("p" to listOf(account.pubKeyHex)), // Events mentioning user
limit = 100,
),
)
relayManager.subscribe(
subId = subId,
filters = filters,
relays = configuredRelays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Skip events from the user themselves (except zaps)
if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) {
return
}
if (event.id !in seenIds) {
seenIds.add(event.id)
val notification =
when (event) {
is ReactionEvent ->
NotificationItem.Reaction(
event = event,
timestamp = event.createdAt,
content = event.content,
)
is RepostEvent, is GenericRepostEvent ->
NotificationItem.Repost(
event = event,
timestamp = event.createdAt,
)
is LnZapEvent -> {
// Extract amount from zap (simplified - full parsing in production)
val amount = event.amount?.toLong()
NotificationItem.Zap(
event = event,
timestamp = event.createdAt,
amount = amount,
)
}
is TextNoteEvent -> {
// Check if it's a reply (has e-tag) or mention
val eTags = event.tags.filter { it.size > 1 && it[0] == "e" }
val isReply = eTags.isNotEmpty()
if (isReply) {
NotificationItem.Reply(event, event.createdAt)
} else {
NotificationItem.Mention(event, event.createdAt)
}
}
else -> NotificationItem.Mention(event, event.createdAt)
}
notifications.add(0, notification)
if (notifications.size > 200) {
val removed = notifications.removeAt(notifications.size - 1)
seenIds.remove(removed.event.id)
}
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// End of stored events
}
},
)
onDispose {
relayManager.unsubscribe(subId)
}
} else {
onDispose { }
}
}
Column(modifier = Modifier.fillMaxSize()) {
FeedHeader(
title = "Notifications",
connectedRelayCount = connectedRelays.size,
onRefresh = { relayManager.connect() },
)
Spacer(Modifier.height(16.dp))
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (notifications.isEmpty()) {
LoadingState("Loading notifications...")
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(notifications, key = { it.event.id }) { notification ->
NotificationCard(notification)
}
}
}
}
}
@Composable
fun NotificationCard(notification: NotificationItem) {
val (icon, label, color) =
when (notification) {
is NotificationItem.Mention -> Triple(Icons.Default.Favorite, "mentioned you", MaterialTheme.colorScheme.primary)
is NotificationItem.Reply -> Triple(Reply, "replied", MaterialTheme.colorScheme.secondary)
is NotificationItem.Reaction ->
Triple(
Icons.Default.Favorite,
"reacted ${notification.content}",
MaterialTheme.colorScheme.tertiary,
)
is NotificationItem.Repost -> Triple(Repost, "reposted", MaterialTheme.colorScheme.primary)
is NotificationItem.Zap -> {
val amountText = notification.amount?.let { " ${it / 1000} sats" } ?: ""
Triple(Zap, "zapped$amountText", MaterialTheme.colorScheme.primary)
}
}
val authorDisplay =
try {
notification.event.pubKey.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: notification.event.pubKey.take(20)
} catch (e: Exception) {
notification.event.pubKey.take(20)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
// Header: icon + label + author + time
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
icon,
contentDescription = label,
tint = color,
modifier = Modifier.size(16.dp),
)
Text(
text = "$authorDisplay $label",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = notification.timestamp.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
// Content (for text-based notifications)
if (notification is NotificationItem.Mention ||
notification is NotificationItem.Reply
) {
Spacer(Modifier.height(8.dp))
Text(
text = notification.event.content.take(200),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
)
}
}
}
}
@@ -0,0 +1,587 @@
/**
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.ReadWrite
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* User profile screen showing user info, follow button, and their posts.
*/
@Composable
fun UserProfileScreen(
pubKeyHex: String,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn?,
onBack: () -> Unit,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
// User metadata
var displayName by remember { mutableStateOf<String?>(null) }
var about by remember { mutableStateOf<String?>(null) }
var picture by remember { mutableStateOf<String?>(null) }
var followersCount by remember { mutableStateOf(0) }
var followingCount by remember { mutableStateOf(0) }
// User's posts
val events = remember { mutableStateListOf<Event>() }
val seenIds = remember { mutableSetOf<String>() }
var postsLoading by remember { mutableStateOf(true) }
var postsError by remember { mutableStateOf<String?>(null) }
var retryTrigger by remember { mutableStateOf(0) }
// Follow state
var isFollowing by remember { mutableStateOf(false) }
var currentContactList by remember { mutableStateOf<ContactListEvent?>(null) }
var isFollowLoading by remember { mutableStateOf(false) }
var followError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// Load current user's contact list (for follow state)
DisposableEffect(relayStatuses, account) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
val contactListSubId = "my-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}"
relayManager.subscribe(
subId = contactListSubId,
filters =
listOf(
Filter(
kinds = listOf(ContactListEvent.KIND), // Kind 3
authors = listOf(account.pubKeyHex),
limit = 1,
),
),
relays = configuredRelays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is ContactListEvent) {
currentContactList = event
isFollowing = event.isTaggedUser(pubKeyHex)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
},
)
onDispose {
relayManager.unsubscribe(contactListSubId)
}
} else {
onDispose {}
}
}
// Subscribe to user metadata and posts
DisposableEffect(relayStatuses, pubKeyHex, retryTrigger) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
postsLoading = true
postsError = null
events.clear()
seenIds.clear()
// Metadata subscription (kind 0)
val metadataSubId = "profile-metadata-$pubKeyHex-${System.currentTimeMillis()}"
relayManager.subscribe(
subId = metadataSubId,
filters =
listOf(
Filter(
kinds = listOf(0), // Metadata
authors = listOf(pubKeyHex),
limit = 1,
),
),
relays = configuredRelays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Parse metadata JSON (simplified - full parsing in production)
try {
val content = event.content
displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name")
about = extractJsonField(content, "about")
picture = extractJsonField(content, "picture")
} catch (e: Exception) {
// Ignore parse errors
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {}
},
)
// Posts subscription (kind 1)
val postsSubId = "profile-posts-$pubKeyHex-${System.currentTimeMillis()}"
relayManager.subscribe(
subId = postsSubId,
filters =
listOf(
Filter(
kinds = listOf(TextNoteEvent.KIND),
authors = listOf(pubKeyHex),
limit = 50,
),
),
relays = configuredRelays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.id !in seenIds) {
seenIds.add(event.id)
events.add(0, event)
if (events.size > 100) {
val removed = events.removeAt(events.size - 1)
seenIds.remove(removed.id)
}
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// At least one relay finished sending events
postsLoading = false
}
},
)
// Set timeout for loading state
val timeoutJob = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default).launch {
kotlinx.coroutines.delay(10000) // 10 second timeout
if (postsLoading) {
postsError = "Request timed out. Check relay connections."
postsLoading = false
}
}
onDispose {
timeoutJob.cancel()
relayManager.unsubscribe(metadataSubId)
relayManager.unsubscribe(postsSubId)
}
} else {
postsLoading = false
postsError = "No relays configured"
onDispose {}
}
}
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
Spacer(Modifier.width(8.dp))
Text(
"Profile",
style = MaterialTheme.typography.headlineMedium,
)
}
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
Column(horizontalAlignment = Alignment.End) {
Button(
onClick = {
scope.launch {
isFollowLoading = true
followError = null
try {
if (isFollowing) {
unfollowUser(pubKeyHex, account, relayManager, currentContactList)
isFollowing = false
} else {
followUser(pubKeyHex, account, relayManager, currentContactList)
isFollowing = true
}
} catch (e: Exception) {
followError = e.message ?: "Failed to update follow status"
} finally {
isFollowLoading = false
}
}
},
enabled = !isFollowLoading,
) {
if (isFollowLoading) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollowing..." else "Following...")
} else {
Icon(
if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd,
contentDescription = if (isFollowing) "Unfollow" else "Follow",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollow" else "Follow")
}
}
followError?.let { error ->
Spacer(Modifier.height(4.dp))
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else {
// Profile card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(24.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top,
) {
// Profile picture placeholder
Surface(
modifier = Modifier.size(80.dp).clip(CircleShape),
color = MaterialTheme.colorScheme.primary,
) {
// TODO: Load actual image from picture URL
}
Column(modifier = Modifier.weight(1f)) {
Text(
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Text(
(pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(32) ?: pubKeyHex.take(32)) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (about != null) {
Spacer(Modifier.height(16.dp))
Text(
about!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Column {
Text(
"$followersCount",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
"Followers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
Text(
"$followingCount",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
"Following",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(Modifier.height(24.dp))
// User's posts
Text(
"Posts",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp),
)
when {
postsError != null -> {
// Error state with retry
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load posts",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
postsError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { retryTrigger++ }) {
Text("Retry")
}
}
}
}
postsLoading -> {
// Loading state
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.material3.CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Loading posts...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
events.isEmpty() -> {
// Empty state (loaded but no posts)
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No posts yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
// Posts loaded successfully
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(events, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,
account = account,
onReply = onCompose,
onNavigateToProfile = onNavigateToProfile,
)
}
}
}
}
}
}
}
/**
* Simple JSON field extractor (not production-ready, just for demo).
*/
private fun extractJsonField(
json: String,
field: String,
): String? {
val regex = """"$field"\s*:\s*"([^"]*)"""".toRegex()
return regex.find(json)?.groupValues?.get(1)
}
/**
* Follows a user by publishing an updated contact list event.
*/
private suspend fun followUser(
pubKeyHex: String,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
currentContactList: ContactListEvent?,
) {
withContext(Dispatchers.IO) {
println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...")
val updatedEvent =
if (currentContactList != null) {
println("[UserProfile] Adding to existing contact list")
// Add to existing contact list
ContactListEvent.followUser(
earlierVersion = currentContactList,
pubKeyHex = pubKeyHex,
signer = account.signer,
)
} else {
println("[UserProfile] Creating new contact list")
// Create new contact list with this user
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(pubKeyHex)),
relayUse = emptyMap<String, ReadWrite>(),
signer = account.signer,
)
}
println("[UserProfile] ContactListEvent created, broadcasting...")
relayManager.broadcastToAll(updatedEvent)
println("[UserProfile] Follow broadcast complete")
}
}
/**
* Unfollows a user by publishing an updated contact list event without them.
*/
private suspend fun unfollowUser(
pubKeyHex: String,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
currentContactList: ContactListEvent?,
) {
withContext(Dispatchers.IO) {
println("[UserProfile] Starting unfollowUser: target=${pubKeyHex.take(8)}...")
if (currentContactList != null) {
println("[UserProfile] Removing from existing contact list")
val updatedEvent =
ContactListEvent.unfollowUser(
earlierVersion = currentContactList,
pubKeyHex = pubKeyHex,
signer = account.signer,
)
println("[UserProfile] ContactListEvent updated, broadcasting...")
relayManager.broadcastToAll(updatedEvent)
println("[UserProfile] Unfollow broadcast complete")
} else {
println("[UserProfile] Error: No contact list to unfollow from")
}
}
}