UI to post nostr reply

This commit is contained in:
Kgothatso Ngako
2026-04-26 02:43:42 +02:00
parent 34216cc6a0
commit 0351fb0db7
7 changed files with 308 additions and 86 deletions

View File

@@ -3,6 +3,22 @@ package ac.cord.auxiliary.compose.database.model.intermdiate
import ac.cord.auxiliary.compose.database.model.NostrEvent
import ac.cord.auxiliary.compose.database.model.Profile
import ac.cord.auxiliary.compose.database.model.Repost
import ac.cord.auxiliary.compose.extensions.toFormattedTimeAndDateString
import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.room.Embedded
import androidx.room.Relation
@@ -32,4 +48,57 @@ data class LocalNostrEvent(
entityColumn = "publicKey"
)
val taggedAuthor: Profile? = null,
)
) {
@Composable
fun RenderNotePreview() {
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
val profile = profile
ProfileAvatar(
profile = profile,
publicKey = nostrEvent.pubKey
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = profile?.displayName ?: profile?.userName ?: nostrEvent.pubKey,
style = MaterialTheme.typography.labelLarge
)
profile?.nip05?.let {
Text(
text = profile.nip05 ?: "",
style = MaterialTheme.typography.labelMedium
)
}
}
}
Text(
modifier = Modifier.fillMaxWidth().padding(
horizontal = 20.dp
),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Start,
overflow = TextOverflow.Ellipsis,
text = nostrEvent.content
)
Row(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalArrangement = Arrangement.Start
) {
Text(
text = nostrEvent.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
}

View File

@@ -2,10 +2,12 @@ package ac.cord.auxiliary.compose.ui.composable
import ac.cord.auxiliary.compose.database.model.NostrEvent
import ac.cord.auxiliary.compose.database.model.Profile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
import ac.cord.auxiliary.compose.extensions.toFormattedTimeAndDateString
import ac.cord.auxiliary.compose.repository.NostrRepository
import ac.cord.auxiliary.compose.ui.composable.widgets.LoadingDataIndicator
import ac.cord.auxiliary.compose.ui.composable.widgets.detail.TextNoteEventDetail
import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar
import ac.cord.auxiliary.compose.ui.theme.AuxTheme
import ac.cord.auxiliary.compose.ui.view.model.WriteNewNoteViewModel
@@ -16,14 +18,21 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NavigateNext
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -31,14 +40,17 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlin.time.Clock
import kotlin.time.Instant
@@ -47,11 +59,15 @@ import kotlin.time.Instant
@Composable
fun WriteNewNoteScreen(
initialWriteNewNoteUIState: WriteNewNoteUIState = WriteNewNoteUIState.Loading,
replyToNostrEventId: HexKey?,
quotedNostrEventId: HexKey?,
onNostrEventPublished: () -> Unit,
nostrRepository: NostrRepository
) {
val writeNewNoteViewModel: WriteNewNoteViewModel = viewModel (
factory = WriteNewNoteViewModel.factory(
replyToNostrEventId = replyToNostrEventId,
quotedNostrEventId = quotedNostrEventId,
initialWriteNewNoteUIState,
nostrRepository
)
@@ -74,55 +90,99 @@ fun WriteNewNoteScreen(
LoadingDataIndicator()
}
is WriteNewNoteUIState.InputPrompt -> {
Text(
"Write new note",
style = MaterialTheme.typography.headlineSmall
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = writeNewNoteViewModel.writeNewNoteFormState.textField.text.value,
onValueChange = {
writeNewNoteViewModel.writeNewNoteFormState.textField.text.value = it
},
isError = writeNewNoteViewModel.writeNewNoteFormState.textField.errorMessage.value != null,
supportingText = writeNewNoteViewModel.writeNewNoteFormState.textField.errorMessage.value?.let {
{
Text(
text = it,
modifier = Modifier.padding(
bottom = 8.dp
)
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.weight(1f)
) {
// TODO: Stick Header with post button...
item {
if (writeNewNoteUIState.inReplyToNostrEvent?.nostrEvent?.kind == TextNoteEvent.KIND) {
writeNewNoteUIState.inReplyToNostrEvent.RenderNotePreview()
} else {
Spacer(
modifier = Modifier.height(1.dp)
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(
text = "What vibrations do you want to send out?",
maxLines = 1,
)
},
placeholder = {
Text(
text = "Type out what you would like to publish",
maxLines = 1,
)
},
leadingIcon = {
ProfileAvatar(
profile = writeNewNoteUIState.localProfile.profile,
size = 35.dp
)
}
)
item {
Column(
modifier = Modifier.fillParentMaxHeight()
) {
if (writeNewNoteUIState.inReplyToNostrEvent != null) {
Text(
modifier = Modifier.padding(
start = 50.dp,
bottom = 10.dp,
),
text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextField(
modifier = Modifier.fillMaxWidth(),
value = writeNewNoteViewModel.writeNewNoteFormState.textField.text.value,
onValueChange = {
writeNewNoteViewModel.writeNewNoteFormState.textField.text.value = it
},
isError = writeNewNoteViewModel.writeNewNoteFormState.textField.errorMessage.value != null,
supportingText = writeNewNoteViewModel.writeNewNoteFormState.textField.errorMessage.value?.let {
{
Text(
text = it,
modifier = Modifier.padding(
bottom = 8.dp
)
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(
text = "What vibrations do you want to send out?",
maxLines = 1,
)
},
placeholder = {
Text(
text = "Type out what you would like to publish",
maxLines = 1,
)
},
leadingIcon = {
ProfileAvatar(
profile = writeNewNoteUIState.localProfile.profile,
size = 35.dp
)
}
)
}
}
}
if (writeNewNoteUIState.inReplyToNostrEvent != null) {
LaunchedEffect(writeNewNoteUIState) {
listState.scrollToItem(1)
}
}
Button(
onClick = {
writeNewNoteViewModel.confirmNote(
localProfile = writeNewNoteUIState.localProfile
localProfile = writeNewNoteUIState.localProfile,
inReplyToNostrEvent = writeNewNoteUIState.inReplyToNostrEvent,
quotedNostrEvent = writeNewNoteUIState.quotedNostrEvent
)
},
) {
@@ -138,43 +198,82 @@ fun WriteNewNoteScreen(
}
is WriteNewNoteUIState.ConfirmNote-> {
// TODO: Get profile for active pubKey
val listState = rememberLazyListState()
Text(
text = writeNewNoteUIState.localProfile.profile.humanReadableNameOrPubkey(),
style = MaterialTheme.typography.bodyLarge
)
LazyColumn(
state = listState,
modifier = Modifier.weight(1f)
) {
item {
if (writeNewNoteUIState.inReplyToNostrEvent?.nostrEvent?.kind == TextNoteEvent.KIND) {
Card {
writeNewNoteUIState.inReplyToNostrEvent.RenderNotePreview()
}
writeNewNoteUIState.localProfile.profile.staticIdentifier()?.let { staticIdentifier ->
Text(
text = staticIdentifier,
style = MaterialTheme.typography.labelSmallEmphasized
)
HorizontalDivider(
modifier = Modifier.padding(20.dp)
)
} else {
Spacer(
modifier = Modifier.height(1.dp)
)
}
}
item {
Column(
modifier = Modifier.fillParentMaxHeight().fillMaxWidth().padding(
10.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = writeNewNoteUIState.localProfile.profile.humanReadableNameOrPubkey(),
style = MaterialTheme.typography.bodyLarge
)
writeNewNoteUIState.localProfile.profile.staticIdentifier()?.let { staticIdentifier ->
Text(
text = staticIdentifier,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
ProfileAvatar(
profile = writeNewNoteUIState.localProfile.profile
)
if (writeNewNoteUIState.inReplyToNostrEvent != null) {
Text(
text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = writeNewNoteUIState.text,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = Clock.System.now().toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
ProfileAvatar(
profile = writeNewNoteUIState.localProfile.profile
)
Text(
text = writeNewNoteUIState.text,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = Clock.System.now().toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
Spacer(
modifier = Modifier.weight(1f)
)
if (writeNewNoteUIState.inReplyToNostrEvent != null) {
LaunchedEffect(writeNewNoteUIState) {
listState.scrollToItem(1)
}
}
Text(
text = "The above will be your new note.",
@@ -231,9 +330,30 @@ private fun WriteNewNoteScreenPreview() {
modifier = Modifier.fillMaxSize()
) {
WriteNewNoteScreen(
replyToNostrEventId = null,
quotedNostrEventId = null,
initialWriteNewNoteUIState =
WriteNewNoteUIState.ConfirmNote(
text = "Yes, I did it. So WHAT?!",
inReplyToNostrEvent = LocalNostrEvent(
nostrEvent = NostrEvent(
id = "eventId",
pubKey = "publicKey",
content = """Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.""".trimIndent(),
tags = emptyArray(),
sig = "",
kind = TextNoteEvent.KIND
),
profile = Profile(
publicKey = "pubKey",
displayName = "John Doe",
nostrEventId = "nostrEventId"
)
),
localProfile = LocalProfileWithFollowing(
nostrEvent = NostrEvent(
id = "eventId",

View File

@@ -237,8 +237,11 @@ fun AuxNavHost(
nostrRepository = databaseNostrRepository
)
}
composable< WriteNewNoteRoute> {
composable<WriteNewNoteRoute> { backStackEntry ->
val route = backStackEntry.toRoute<WriteNewNoteRoute>()
WriteNewNoteScreen(
replyToNostrEventId = route.inReplyToEventId,
quotedNostrEventId = route.quotedEventId,
onNostrEventPublished = {
applicationMainScope.launch {
navController.popBackStack()

View File

@@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class WriteNewNoteRoute(
val inReplyToEventId: String? = null,
val quotedEventId: String? = null,
): Route() {
}

View File

@@ -4,6 +4,7 @@ import ac.cord.auxiliary.compose.database.model.NostrEvent
import ac.cord.auxiliary.compose.database.model.Profile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.extensions.toFormattedTimeAndDateString
import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar
import ac.cord.auxiliary.compose.ui.theme.AuxTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -50,7 +51,6 @@ fun TextNoteEventDetail(
onNavigateBack: () -> Unit,
onNavigateToWriteAReply: (String) -> Unit
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -91,10 +91,9 @@ fun TextNoteEventDetail(
) {
val profile = localNostrEvent.profile
Icon(
Icons.Default.Person,
modifier = Modifier.size(50.dp),
contentDescription = profile?.displayName ?: profile?.userName ?: "Profile picture"
ProfileAvatar(
profile = localNostrEvent.profile,
publicKey = localNostrEvent.nostrEvent.pubKey
)
Column(

View File

@@ -1,5 +1,6 @@
package ac.cord.auxiliary.compose.ui.view.model
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
import ac.cord.auxiliary.compose.managers.SeedManager
@@ -17,13 +18,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
class WriteNewNoteViewModel(
val initialWriteNewNoteUIState: WriteNewNoteUIState,
val replyToNostrEventId: HexKey?,
val quotedNostrEventId: HexKey?,
initialWriteNewNoteUIState: WriteNewNoteUIState,
val writeNewNoteFormState: WriteNewNoteFormState = WriteNewNoteFormState(),
val nostrRepository: NostrRepository
): ViewModel() {
@@ -31,12 +37,16 @@ class WriteNewNoteViewModel(
private const val TAG = "WriteNewNoteViewModel"
fun factory(
replyToNostrEventId: HexKey?,
quotedNostrEventId: HexKey?,
initialWriteNewNoteUIState: WriteNewNoteUIState,
nostrRepository: NostrRepository
nostrRepository: NostrRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
WriteNewNoteViewModel(
initialWriteNewNoteUIState,
replyToNostrEventId = replyToNostrEventId,
quotedNostrEventId = quotedNostrEventId,
initialWriteNewNoteUIState = initialWriteNewNoteUIState,
nostrRepository = nostrRepository
)
}
@@ -57,6 +67,12 @@ class WriteNewNoteViewModel(
fun observeActiveUserProfileWithFollowing() {
logger.d("observeActiveUserProfileWithFollowing")
viewModelScope.launch(Dispatchers.IO) {
val nostrEventBeingRepliedTo = replyToNostrEventId?.let {
nostrRepository.observeLocalNostrEventById(
it
).firstOrNull()
}
val activePublicKey = SeedManager.activePublicKey().toHexKey()
// TODO: Get note being replied too...
@@ -67,7 +83,8 @@ class WriteNewNoteViewModel(
).collect { profileWithFollowing ->
writeNewNoteUIState = if (profileWithFollowing != null) {
WriteNewNoteUIState.InputPrompt(
localProfile = profileWithFollowing
localProfile = profileWithFollowing,
inReplyToNostrEvent = nostrEventBeingRepliedTo
)
} else {
WriteNewNoteUIState.Error
@@ -76,12 +93,18 @@ class WriteNewNoteViewModel(
}
}
fun confirmNote(localProfile: LocalProfileWithFollowing) {
fun confirmNote(
localProfile: LocalProfileWithFollowing,
inReplyToNostrEvent: LocalNostrEvent?,
quotedNostrEvent: LocalNostrEvent?
) {
validateInput().let { isInputValid ->
if (isInputValid) {
writeNewNoteUIState =
WriteNewNoteUIState.ConfirmNote(
localProfile = localProfile,
inReplyToNostrEvent = inReplyToNostrEvent,
quotedNostrEvent = quotedNostrEvent,
text = writeNewNoteFormState.textField.text.value,
)
}

View File

@@ -1,7 +1,9 @@
package ac.cord.auxiliary.compose.ui.view.state
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
import com.vitorpamplona.quartz.nip01Core.core.HexKey
abstract class WriteNewNoteUIState {
data object Loading: WriteNewNoteUIState()
@@ -9,11 +11,16 @@ abstract class WriteNewNoteUIState {
data object Error: WriteNewNoteUIState()
data class InputPrompt(
val localProfile: LocalProfileWithFollowing
val localProfile: LocalProfileWithFollowing,
val inReplyToNostrEvent: LocalNostrEvent? = null,
val quotedNostrEvent: LocalNostrEvent? = null,
val replayToId: HexKey? = null,
): WriteNewNoteUIState()
data class ConfirmNote(
val localProfile: LocalProfileWithFollowing,
val inReplyToNostrEvent: LocalNostrEvent? = null,
val quotedNostrEvent: LocalNostrEvent? = null,
val text: String,
) : WriteNewNoteUIState()