Mention other users.

This commit is contained in:
Kgothatso Ngako
2026-05-02 21:52:30 +02:00
parent 7ba7bbdfa7
commit eb7165b124
5 changed files with 157 additions and 17 deletions

View File

@@ -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<Profile>
@Query("SELECT * FROM Profile WHERE createdAt > :createdAt LIMIT :limit")
fun getAllProfiles(limit: Int = 21, createdAt: Instant = GENESIS_AT): List<Profile>
@Query("SELECT * FROM NostrEvent WHERE kind = 0 AND content LIKE '%' || :searchTerm || '%' LIMIT :limit")
fun filterProfiles(searchTerm: String, limit: Int = 21): List<LocalProfile>

View File

@@ -237,6 +237,7 @@ class DatabaseNostrRepository(
override suspend fun createNewTextNote(
publicKey: HexKey,
mentionedPublicKeys: List<HexKey>,
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<HexKey>()
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(

View File

@@ -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<HexKey>,
textInput: String,
inReplyToNostrEvent: LocalNostrEvent?,
quotedNostrEvent: LocalNostrEvent?,
@@ -145,6 +147,7 @@ interface NostrRepository {
override suspend fun createNewTextNote(
publicKey: HexKey,
mentionedPublicKeys: List<HexKey>,
textInput: String,
inReplyToNostrEvent: LocalNostrEvent?,
quotedNostrEvent: LocalNostrEvent?,

View File

@@ -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<Profile> = 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<ContentSegment.NostrProfileSegment>()
// 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()

View File

@@ -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<Profile>()
private set
var mentionedProfiles = mutableStateListOf<Profile>()
private set
var atSymbolRange = mutableStateOf<TextRange?>(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<ContentSegment.NostrProfileSegment>().map { it.pubkey }
nostrRepository.createNewTextNote(
publicKey = SeedManager.activePublicKey().toHexKey(),
mentionedPublicKeys = mentionedPublicKeys,
textInput = writeNewNoteFormState.textField.textFieldState.text.toString(),
inReplyToNostrEvent = inReplyToNostrEvent,
quotedNostrEvent = quotedNostrEvent,