initial desktop phase1
This commit is contained in:
+19
@@ -99,6 +99,25 @@ open class RelayConnectionManager(
|
|||||||
client.send(event, relays)
|
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(
|
private fun updateRelayStatus(
|
||||||
url: NormalizedRelayUrl,
|
url: NormalizedRelayUrl,
|
||||||
update: (RelayStatus) -> RelayStatus,
|
update: (RelayStatus) -> RelayStatus,
|
||||||
|
|||||||
+10
-1
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.ui.note
|
package com.vitorpamplona.amethyst.commons.ui.note
|
||||||
|
|
||||||
|
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
|
||||||
@@ -50,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
|||||||
*/
|
*/
|
||||||
data class NoteDisplayData(
|
data class NoteDisplayData(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val pubKeyHex: String,
|
||||||
val pubKeyDisplay: String,
|
val pubKeyDisplay: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
@@ -64,6 +66,7 @@ fun NoteCard(
|
|||||||
note: NoteDisplayData,
|
note: NoteDisplayData,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
|
onAuthorClick: ((String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val richTextParser = remember { RichTextParser() }
|
val richTextParser = remember { RichTextParser() }
|
||||||
val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) }
|
val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) }
|
||||||
@@ -82,12 +85,18 @@ fun NoteCard(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Author (truncated)
|
// Author (truncated, clickable)
|
||||||
Text(
|
Text(
|
||||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
modifier =
|
||||||
|
if (onAuthorClick != null) {
|
||||||
|
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
|
|||||||
+1
@@ -38,6 +38,7 @@ fun Event.toNoteDisplayData(): NoteDisplayData {
|
|||||||
|
|
||||||
return NoteDisplayData(
|
return NoteDisplayData(
|
||||||
id = id,
|
id = id,
|
||||||
|
pubKeyHex = pubKey,
|
||||||
pubKeyDisplay = npub,
|
pubKeyDisplay = npub,
|
||||||
content = content,
|
content = content,
|
||||||
createdAt = createdAt,
|
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.NotificationsPlaceholder
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder
|
import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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.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.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() =
|
fun main() =
|
||||||
application {
|
application {
|
||||||
@@ -92,6 +116,7 @@ fun main() =
|
|||||||
height = 800.dp,
|
height = 800.dp,
|
||||||
position = WindowPosition.Aligned(Alignment.Center),
|
position = WindowPosition.Aligned(Alignment.Center),
|
||||||
)
|
)
|
||||||
|
var showComposeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
@@ -102,25 +127,58 @@ fun main() =
|
|||||||
Menu("File") {
|
Menu("File") {
|
||||||
Item(
|
Item(
|
||||||
"New Note",
|
"New Note",
|
||||||
shortcut = KeyShortcut(Key.N, ctrl = true),
|
shortcut =
|
||||||
onClick = { /* TODO: Open new note dialog */ },
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.N, meta = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.N, ctrl = true)
|
||||||
|
},
|
||||||
|
onClick = { showComposeDialog = true },
|
||||||
)
|
)
|
||||||
Separator()
|
Separator()
|
||||||
Item(
|
Item(
|
||||||
"Settings",
|
"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 */ },
|
onClick = { /* TODO: Open settings */ },
|
||||||
)
|
)
|
||||||
Separator()
|
Separator()
|
||||||
Item(
|
Item(
|
||||||
"Quit",
|
"Quit",
|
||||||
shortcut = KeyShortcut(Key.Q, ctrl = true),
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.Q, meta = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.Q, ctrl = true)
|
||||||
|
},
|
||||||
onClick = ::exitApplication,
|
onClick = ::exitApplication,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Menu("Edit") {
|
Menu("Edit") {
|
||||||
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { })
|
Item(
|
||||||
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { })
|
"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") {
|
Menu("View") {
|
||||||
Item("Feed", onClick = { })
|
Item("Feed", onClick = { })
|
||||||
@@ -133,13 +191,21 @@ fun main() =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
App()
|
App(
|
||||||
|
showComposeDialog = showComposeDialog,
|
||||||
|
onShowComposeDialog = { showComposeDialog = true },
|
||||||
|
onDismissComposeDialog = { showComposeDialog = false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(
|
||||||
var currentScreen by remember { mutableStateOf(AppScreen.Feed) }
|
showComposeDialog: Boolean,
|
||||||
|
onShowComposeDialog: () -> Unit,
|
||||||
|
onDismissComposeDialog: () -> Unit,
|
||||||
|
) {
|
||||||
|
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) }
|
||||||
val relayManager = remember { DesktopRelayConnectionManager() }
|
val relayManager = remember { DesktopRelayConnectionManager() }
|
||||||
val accountManager = remember { AccountManager() }
|
val accountManager = remember { AccountManager() }
|
||||||
val accountState by accountManager.accountState.collectAsState()
|
val accountState by accountManager.accountState.collectAsState()
|
||||||
@@ -163,17 +229,29 @@ fun App() {
|
|||||||
is AccountState.LoggedOut -> {
|
is AccountState.LoggedOut -> {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
onLoginSuccess = { currentScreen = AppScreen.Feed },
|
onLoginSuccess = { currentScreen = DesktopScreen.Feed },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is AccountState.LoggedIn -> {
|
is AccountState.LoggedIn -> {
|
||||||
|
val account = accountState as AccountState.LoggedIn
|
||||||
|
|
||||||
MainContent(
|
MainContent(
|
||||||
currentScreen = currentScreen,
|
currentScreen = currentScreen,
|
||||||
onScreenChange = { currentScreen = it },
|
onScreenChange = { currentScreen = it },
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
accountManager = accountManager,
|
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
|
@Composable
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
currentScreen: AppScreen,
|
currentScreen: DesktopScreen,
|
||||||
onScreenChange: (AppScreen) -> Unit,
|
onScreenChange: (DesktopScreen) -> Unit,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
|
onShowComposeDialog: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
// Sidebar Navigation
|
// Sidebar Navigation
|
||||||
@@ -199,36 +278,36 @@ fun MainContent(
|
|||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
|
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
|
||||||
label = { Text("Feed") },
|
label = { Text("Feed") },
|
||||||
selected = currentScreen == AppScreen.Feed,
|
selected = currentScreen == DesktopScreen.Feed,
|
||||||
onClick = { onScreenChange(AppScreen.Feed) },
|
onClick = { onScreenChange(DesktopScreen.Feed) },
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
||||||
label = { Text("Search") },
|
label = { Text("Search") },
|
||||||
selected = currentScreen == AppScreen.Search,
|
selected = currentScreen == DesktopScreen.Search,
|
||||||
onClick = { onScreenChange(AppScreen.Search) },
|
onClick = { onScreenChange(DesktopScreen.Search) },
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
||||||
label = { Text("DMs") },
|
label = { Text("DMs") },
|
||||||
selected = currentScreen == AppScreen.Messages,
|
selected = currentScreen == DesktopScreen.Messages,
|
||||||
onClick = { onScreenChange(AppScreen.Messages) },
|
onClick = { onScreenChange(DesktopScreen.Messages) },
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
|
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
|
||||||
label = { Text("Alerts") },
|
label = { Text("Alerts") },
|
||||||
selected = currentScreen == AppScreen.Notifications,
|
selected = currentScreen == DesktopScreen.Notifications,
|
||||||
onClick = { onScreenChange(AppScreen.Notifications) },
|
onClick = { onScreenChange(DesktopScreen.Notifications) },
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||||
label = { Text("Profile") },
|
label = { Text("Profile") },
|
||||||
selected = currentScreen == AppScreen.Profile,
|
selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
|
||||||
onClick = { onScreenChange(AppScreen.Profile) },
|
onClick = { onScreenChange(DesktopScreen.MyProfile) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
@@ -238,8 +317,8 @@ fun MainContent(
|
|||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
label = { Text("Settings") },
|
label = { Text("Settings") },
|
||||||
selected = currentScreen == AppScreen.Settings,
|
selected = currentScreen == DesktopScreen.Settings,
|
||||||
onClick = { onScreenChange(AppScreen.Settings) },
|
onClick = { onScreenChange(DesktopScreen.Settings) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -252,12 +331,41 @@ fun MainContent(
|
|||||||
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
|
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
|
||||||
) {
|
) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
AppScreen.Feed -> FeedScreen(relayManager)
|
DesktopScreen.Feed ->
|
||||||
AppScreen.Search -> SearchPlaceholder()
|
FeedScreen(
|
||||||
AppScreen.Messages -> MessagesPlaceholder()
|
relayManager = relayManager,
|
||||||
AppScreen.Notifications -> NotificationsPlaceholder()
|
account = account,
|
||||||
AppScreen.Profile -> ProfileScreen(account, accountManager)
|
onCompose = onShowComposeDialog,
|
||||||
AppScreen.Settings -> RelaySettingsScreen(relayManager)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+227
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+248
-32
@@ -22,19 +22,37 @@ package com.vitorpamplona.amethyst.desktop.ui
|
|||||||
|
|
||||||
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.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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.components.LoadingState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
|
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
|
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.client.reqs.IRequestListener
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note card with action buttons.
|
||||||
|
*/
|
||||||
@Composable
|
@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 connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
val events = remember { mutableStateListOf<Event>() }
|
val events = remember { mutableStateListOf<Event>() }
|
||||||
val seenIds = remember { mutableSetOf<String>() }
|
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,
|
// Load followed users for Following feed mode
|
||||||
// not connectedRelays (which is empty until subscriptions trigger connections)
|
DisposableEffect(relayStatuses, account, feedMode) {
|
||||||
val configuredRelays = relayStatuses.keys
|
val configuredRelays = relayStatuses.keys
|
||||||
|
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||||
DisposableEffect(configuredRelays) {
|
val contactListSubId = "feed-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}"
|
||||||
if (configuredRelays.isNotEmpty()) {
|
|
||||||
val subId = "global-feed-${System.currentTimeMillis()}"
|
|
||||||
val filters =
|
|
||||||
listOf(
|
|
||||||
Filter(
|
|
||||||
kinds = listOf(TextNoteEvent.KIND),
|
|
||||||
limit = 50,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
relayManager.subscribe(
|
relayManager.subscribe(
|
||||||
subId = subId,
|
subId = contactListSubId,
|
||||||
filters = filters,
|
filters =
|
||||||
|
listOf(
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(ContactListEvent.KIND),
|
||||||
|
authors = listOf(account.pubKeyHex),
|
||||||
|
limit = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
listener =
|
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 {
|
object : IRequestListener {
|
||||||
override fun onEvent(
|
override fun onEvent(
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -97,40 +221,132 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
|
|||||||
// End of stored events
|
// End of stored events
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
relayManager.unsubscribe(subId)
|
relayManager.unsubscribe(subId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDispose {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onDispose { }
|
onDispose {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
FeedHeader(
|
// Header with compose button
|
||||||
title = "Global Feed",
|
Row(
|
||||||
connectedRelayCount = connectedRelays.size,
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
onRefresh = { relayManager.connect() },
|
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()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
LoadingState("Connecting to relays...")
|
LoadingState("Connecting to relays...")
|
||||||
|
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
||||||
|
LoadingState("Loading followed users...")
|
||||||
} else if (events.isEmpty()) {
|
} else if (events.isEmpty()) {
|
||||||
LoadingState("Loading notes...")
|
LoadingState(
|
||||||
|
if (feedMode == FeedMode.FOLLOWING) {
|
||||||
|
"No notes from followed users yet"
|
||||||
|
} else {
|
||||||
|
"Loading notes..."
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(events, key = { it.id }) { event ->
|
items(events, key = { it.id }) { event ->
|
||||||
// Use NoteCard from commons
|
FeedNoteCard(
|
||||||
NoteCard(
|
event = event,
|
||||||
note = event.toNoteDisplayData(),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+305
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+587
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user