From eb7165b124c614a33770788394d0a0c2110bb614 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 2 May 2026 21:52:30 +0200 Subject: [PATCH] Mention other users. --- .../compose/database/dao/ProfileDao.kt | 6 +- .../repository/DatabaseNostrRepository.kt | 25 ++++- .../compose/repository/NostrRepository.kt | 3 + .../ui/composable/WriteNewNoteScreen.kt | 91 +++++++++++++++++-- .../ui/view/model/WriteNewNoteViewModel.kt | 49 +++++++++- 5 files changed, 157 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ProfileDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ProfileDao.kt index 6ca6a073..ac2f2f7d 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ProfileDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ProfileDao.kt @@ -1,5 +1,6 @@ package ac.cord.auxiliary.compose.database.dao +import ac.cord.auxiliary.compose.database.GENESIS_AT import ac.cord.auxiliary.compose.database.model.Profile import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowers @@ -10,11 +11,12 @@ import androidx.room3.OnConflictStrategy.Companion.IGNORE import androidx.room3.Query import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant @Dao interface ProfileDao { - @Query("SELECT * FROM Profile LIMIT :limit") - fun getAllProfiles(limit: Int = 21): List + @Query("SELECT * FROM Profile WHERE createdAt > :createdAt LIMIT :limit") + fun getAllProfiles(limit: Int = 21, createdAt: Instant = GENESIS_AT): List @Query("SELECT * FROM NostrEvent WHERE kind = 0 AND content LIKE '%' || :searchTerm || '%' LIMIT :limit") fun filterProfiles(searchTerm: String, limit: Int = 21): List diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt index ac222896..39cf8b6f 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt @@ -237,6 +237,7 @@ class DatabaseNostrRepository( override suspend fun createNewTextNote( publicKey: HexKey, + mentionedPublicKeys: List, textInput: String, inReplyToNostrEvent: LocalNostrEvent?, quotedNostrEvent: LocalNostrEvent?, @@ -271,12 +272,16 @@ class DatabaseNostrRepository( ) it.profile?.let { profile -> - val ptags = arrayOf( - profile, - // Other profiles tagged in the event... - ).map { notifyProfile -> + val publicKeys = mutableSetOf() + publicKeys.addAll( + mentionedPublicKeys.toSet() + ) + + publicKeys.add(profile.publicKey) + + val ptags = publicKeys.map { publicKey -> PTag( - notifyProfile.publicKey, + publicKey, // TODO: Add relayHints... ) } @@ -285,6 +290,16 @@ class DatabaseNostrRepository( } } + if (inReplyToNostrEvent == null || inReplyToNostrEvent.profile == null) { + val pTags = mentionedPublicKeys.toSet().map { + PTag( + pubKey = it + ) + } + + notify(pTags) + } + quotedNostrEvent?.let { it.profile?.let { profile -> val ptags = listOf( diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt index fd8311de..ecbda582 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt @@ -18,6 +18,7 @@ import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilte import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import fr.acinq.secp256k1.Hex import kotlinx.coroutines.flow.Flow interface NostrRepository { @@ -46,6 +47,7 @@ interface NostrRepository { suspend fun createNewTextNote( publicKey: HexKey, + mentionedPublicKeys: List, textInput: String, inReplyToNostrEvent: LocalNostrEvent?, quotedNostrEvent: LocalNostrEvent?, @@ -145,6 +147,7 @@ interface NostrRepository { override suspend fun createNewTextNote( publicKey: HexKey, + mentionedPublicKeys: List, textInput: String, inReplyToNostrEvent: LocalNostrEvent?, quotedNostrEvent: LocalNostrEvent?, diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt index 36d74b09..a79f4dc5 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt @@ -1,5 +1,6 @@ package ac.cord.auxiliary.compose.ui.composable +import ac.cord.auxiliary.compose.database.model.Mention import ac.cord.auxiliary.compose.database.model.NostrEvent import ac.cord.auxiliary.compose.database.model.Profile import ac.cord.auxiliary.compose.database.model.intermdiate.LocalMention @@ -10,11 +11,13 @@ import ac.cord.auxiliary.compose.repository.NostrRepository import ac.cord.auxiliary.compose.ui.composable.widgets.LoadingDataIndicator import ac.cord.auxiliary.compose.ui.composable.widgets.content.ContentParser import ac.cord.auxiliary.compose.ui.composable.widgets.content.ContentSegment +import ac.cord.auxiliary.compose.ui.composable.widgets.content.RichContent import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileColor import ac.cord.auxiliary.compose.ui.theme.AuxTheme import ac.cord.auxiliary.compose.ui.view.model.WriteNewNoteViewModel import ac.cord.auxiliary.compose.ui.view.state.WriteNewNoteUIState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -25,11 +28,14 @@ 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.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.NavigateNext import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -41,7 +47,11 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +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.text.SpanStyle @@ -50,11 +60,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.time.Clock -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Composable fun WriteNewNoteScreen( initialWriteNewNoteUIState: WriteNewNoteUIState = WriteNewNoteUIState.Loading, @@ -125,7 +136,7 @@ fun WriteNewNoteScreen( ) } - val mentionedProfiles: List = emptyList() + TextField( modifier = Modifier.fillMaxWidth(), @@ -168,20 +179,30 @@ fun WriteNewNoteScreen( size = 35.dp ) }, + inputTransformation = InputTransformation { + if (changes.changeCount > 0) { + val range = changes.getRange(changes.changeCount-1) + val newInput = asCharSequence().substring(range.start, range.end) + + if (newInput == "@") { + writeNewNoteViewModel.atSymbolRange.value = range + } + } + }, outputTransformation = OutputTransformation { val content = originalText.toString() val nostrProfiles = ContentParser.parse(content.trimEnd('\n', '\r'), emptyMap(), emptyMap(), trimBlankLines = true) .filterIsInstance() // TODO: We might want to order these profiles by the startIndex so that we can replace the better - if (mentionedProfiles.isNotEmpty()) { + if (writeNewNoteViewModel.mentionedProfiles.isNotEmpty()) { for (nostrProfile in nostrProfiles) { - mentionedProfiles.find { mentionedProfile -> mentionedProfile.publicKey == nostrProfile.pubkey }?.let { mentionedProfile -> + writeNewNoteViewModel.mentionedProfiles.find { mentionedProfile -> mentionedProfile.publicKey == nostrProfile.pubkey }?.let { mentionedProfile -> val profileMention = "@${mentionedProfile.humanReadableNameOrPubkey()}" replace( start = nostrProfile.startIndex, - end = nostrProfile.endIndex, + end = nostrProfile.endIndex + 1, text = profileMention ) @@ -190,7 +211,7 @@ fun WriteNewNoteScreen( color = ProfileColor.fromPublicKey(mentionedProfile.publicKey) ), nostrProfile.startIndex, - profileMention.length + nostrProfile.startIndex + profileMention.length ) } } @@ -198,6 +219,33 @@ fun WriteNewNoteScreen( } ) + // TODO: Dropdown for the mentioned users + + DropdownMenu( + expanded = writeNewNoteViewModel.atSymbolRange.value != null, + onDismissRequest = { + writeNewNoteViewModel.atSymbolRange.value = null + } + ) { + writeNewNoteViewModel.mentionableProfiles.forEach { profile -> + DropdownMenuItem( + text = { + Text( + text = profile.humanReadableNameOrPubkey() + ) + }, + leadingIcon = { + ProfileAvatar( + profile = profile + ) + }, + onClick = { + writeNewNoteViewModel.addProfileToMentionedProfiles(profile) + writeNewNoteViewModel.atSymbolRange.value = null + } + ) + } + } if (writeNewNoteUIState.quotedNostrEvent?.nostrEvent?.kind == TextNoteEvent.KIND) { Card( modifier = Modifier.padding( @@ -298,12 +346,37 @@ fun WriteNewNoteScreen( ) } - Text( - text = writeNewNoteUIState.text, + RichContent( + // Hack to have the thing rendered for a preview... + nostrEvent = NostrEvent( + id = "", + content = writeNewNoteUIState.text, + sig = "", + tags = emptyArray(), + kind = 1, + createdAt = Clock.System.now(), + pubKey = "" + ), + localQuotedNostrEvent = null, + mentionedProfiles = writeNewNoteViewModel.mentionableProfiles.map { + LocalMention( + mention = Mention( + mentionedPublicKey = it.publicKey, + mentioningNostrEventId = "" + ), + profile = it + ) + }, style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center + // TODO: Add TextAlign.Center ) +// Text( +// text = writeNewNoteUIState.text, +// style = MaterialTheme.typography.bodySmall, +// textAlign = TextAlign.Center +// ) + if (writeNewNoteUIState.quotedNostrEvent?.nostrEvent?.kind == TextNoteEvent.KIND) { Card { writeNewNoteUIState.quotedNostrEvent.RenderNotePreview() diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/WriteNewNoteViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/WriteNewNoteViewModel.kt index 57d88920..d9deef68 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/WriteNewNoteViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/WriteNewNoteViewModel.kt @@ -1,9 +1,12 @@ package ac.cord.auxiliary.compose.ui.view.model +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.managers.SeedManager import ac.cord.auxiliary.compose.repository.NostrRepository +import ac.cord.auxiliary.compose.ui.composable.widgets.content.ContentParser +import ac.cord.auxiliary.compose.ui.composable.widgets.content.ContentSegment import ac.cord.auxiliary.compose.ui.view.state.WriteNewNoteUIState import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf @@ -13,11 +16,14 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import ac.cord.auxiliary.compose.ui.view.state.form.WriteNewNoteFormState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.firstOrNull @@ -58,8 +64,45 @@ class WriteNewNoteViewModel( var writeNewNoteUIState: WriteNewNoteUIState by mutableStateOf(initialWriteNewNoteUIState) private set + var mentionableProfiles = mutableStateListOf() + private set + + var mentionedProfiles = mutableStateListOf() + private set + + var atSymbolRange = mutableStateOf(null) + fun initiate() { observeActiveUserProfileWithFollowing() + loadSearchableProfiles() + } + + fun loadSearchableProfiles() { + viewModelScope.launch(Dispatchers.IO) { + mentionableProfiles.addAll( + nostrRepository.searchableProfiles() + ) + } + } + + fun addProfileToMentionedProfiles(profile: Profile) { + atSymbolRange.value?.let { range -> + mentionedProfiles.add(profile) + writeNewNoteFormState.textField.textFieldState.edit { + val mentionProfile = "nostr:${ + NProfile.create( + profile.publicKey, + null // TODO: Get the relay this profile was seen from... + ) + }" + + replace( + range.start, + range.end, + mentionProfile + ) + } + } } fun observeActiveUserProfileWithFollowing() { @@ -80,7 +123,6 @@ class WriteNewNoteViewModel( val activePublicKey = SeedManager.activePublicKey().toHexKey() // TODO: Get note being replied too... - // TODO: Get note being quoted... // TODO: Get users being mentioned... nostrRepository.observeProfileWithFollowing( activePublicKey @@ -136,8 +178,13 @@ class WriteNewNoteViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { + val mentionedPublicKeys = ContentParser.parse( + writeNewNoteFormState.textField.textFieldState.text.toString(), + ).filterIsInstance().map { it.pubkey } + nostrRepository.createNewTextNote( publicKey = SeedManager.activePublicKey().toHexKey(), + mentionedPublicKeys = mentionedPublicKeys, textInput = writeNewNoteFormState.textField.textFieldState.text.toString(), inReplyToNostrEvent = inReplyToNostrEvent, quotedNostrEvent = quotedNostrEvent,