initial desktop phase1
This commit is contained in:
+19
@@ -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,
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.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)
|
||||
}
|
||||
}
|
||||
+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