Show recent searches from local input

This commit is contained in:
Kgothatso Ngako
2026-04-02 00:18:29 +02:00
parent e3aae6620d
commit 36facaf6a7
10 changed files with 192 additions and 118 deletions

View File

@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "770e86deaca12c1e4d82a71f6613da37",
"identityHash": "5f7afc4947b08cff2f59cfb44bfbf281",
"entities": [
{
"tableName": "BroadcastNostrEventReceipt",
@@ -753,6 +753,41 @@
}
]
},
{
"tableName": "RecentSearch",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))",
"fields": [
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "synchronizationFilters",
"columnName": "synchronizationFilters",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"query"
]
}
},
{
"tableName": "Repost",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostedPostId` TEXT NOT NULL, `repostedPostTags` TEXT NOT NULL, `repostedPostCreatedAt` INTEGER NOT NULL, `repostedPostContent` TEXT NOT NULL, `repostedPostAuthorPublicKey` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedPostAuthorPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedPostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@@ -1374,7 +1409,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '770e86deaca12c1e4d82a71f6613da37')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f7afc4947b08cff2f59cfb44bfbf281')"
]
}
}

View File

@@ -8,6 +8,7 @@ import ac.aux.compose.database.dao.NostrEventDao
import ac.aux.compose.database.dao.PostDao
import ac.aux.compose.database.dao.ProfileDao
import ac.aux.compose.database.dao.ReactionDao
import ac.aux.compose.database.dao.RecentSearchDao
import ac.aux.compose.database.dao.RepostDao
import ac.aux.compose.database.dao.SynchronizeNostrEventRequestDao
import ac.aux.compose.database.dao.SynchronizeNostrEventResultDao
@@ -19,6 +20,7 @@ import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.Reaction
import ac.aux.compose.database.model.RecentSearch
import ac.aux.compose.database.model.Repost
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.SynchronizeNostrEventResult
@@ -41,6 +43,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
Post::class,
Profile::class,
Reaction::class,
RecentSearch::class,
Repost::class,
SynchronizeNostrEventRequest::class,
SynchronizeNostrEventResult::class,
@@ -59,6 +62,8 @@ abstract class AuxDatabase: RoomDatabase() {
abstract fun postDao(): PostDao
abstract fun profileDao(): ProfileDao
abstract fun reactionDao(): ReactionDao
abstract fun recentSearchDao(): RecentSearchDao
abstract fun repostDao(): RepostDao
abstract fun synchronizeNostrEventRequestDao(): SynchronizeNostrEventRequestDao

View File

@@ -0,0 +1,25 @@
package ac.aux.compose.database.dao
import ac.aux.compose.database.model.RecentSearch
import ac.aux.compose.database.model.Zap
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface RecentSearchDao {
@Query("SELECT * FROM RecentSearch WHERE `query` = :query")
fun observeRecentSearchByQuery(query: String): Flow<RecentSearch?>
@Query("SELECT * FROM RecentSearch ORDER BY createdAt DESC LIMIT :limit ")
fun getAllRecentSearches(limit: Int = 21): List<RecentSearch>
@Delete
fun deleteRecentSearch(recentSearch: RecentSearch)
@Upsert
suspend fun upsert(recentSearch: RecentSearch)
}

View File

@@ -0,0 +1,41 @@
package ac.aux.compose.database.model
import ac.aux.compose.database.model.traits.TimestampedEntity
import ac.aux.compose.database.model.typealiases.SynchronizationFilterArray
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlin.time.Clock
import kotlin.time.Instant
@Entity
data class RecentSearch(
@PrimaryKey
val query: String,
val synchronizationFilters: SynchronizationFilterArray? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
): TimestampedEntity {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as RecentSearch
if (query != other.query) return false
if (!synchronizationFilters.contentEquals(other.synchronizationFilters)) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
return true
}
override fun hashCode(): Int {
var result = query.hashCode()
result = 31 * result + (synchronizationFilters?.contentHashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
return result
}
}

View File

@@ -6,11 +6,13 @@ import ac.aux.compose.database.model.BroadcastNostrEventReceipt
import ac.aux.compose.database.model.BroadcastNostrEventRequest
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.RecentSearch
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest
import ac.aux.compose.database.model.intermdiate.LocalNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalProfile
import ac.aux.compose.database.model.typealiases.SynchronizationFilterArray
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import co.touchlab.kermit.Logger
@@ -324,8 +326,23 @@ class DatabaseNostrRepository(
return database.profileDao().getAllProfiles()
}
override suspend fun recentSearches(): List<String> {
TODO("Not yet implemented")
override suspend fun getRecentSearches(): List<RecentSearch> {
return database.recentSearchDao().getAllRecentSearches()
}
override suspend fun removeRecentSearch(recentSearch: RecentSearch) {
database.recentSearchDao().deleteRecentSearch(recentSearch)
}
override suspend fun saveSearchQuery(
query: String,
synchronizationFilterArray: SynchronizationFilterArray?
) {
database.recentSearchDao().upsert(
RecentSearch(
query = query,
synchronizationFilters = synchronizationFilterArray
)
)
}
}

View File

@@ -4,11 +4,13 @@ import ac.aux.compose.database.model.BroadcastNostrEventReceipt
import ac.aux.compose.database.model.BroadcastNostrEventRequest
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.RecentSearch
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest
import ac.aux.compose.database.model.intermdiate.LocalNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalProfile
import ac.aux.compose.database.model.typealiases.SynchronizationFilterArray
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
@@ -73,8 +75,11 @@ interface NostrRepository {
suspend fun searchableProfiles(): List<Profile>
suspend fun recentSearches(): List<String>
suspend fun getRecentSearches(): List<RecentSearch>
suspend fun removeRecentSearch(recentSearch: RecentSearch)
suspend fun saveSearchQuery(query: String, synchronizationFilterArray: SynchronizationFilterArray? = null)
companion object {
val NO_OP_NOSTR_REPOSITORY = object : NostrRepository {
@@ -168,7 +173,18 @@ interface NostrRepository {
TODO("Not yet implemented")
}
override suspend fun recentSearches(): List<String> {
override suspend fun getRecentSearches(): List<RecentSearch> {
TODO("Not yet implemented")
}
override suspend fun removeRecentSearch(recentSearch: RecentSearch) {
TODO("Not yet implemented")
}
override suspend fun saveSearchQuery(
query: String,
synchronizationFilterArray: SynchronizationFilterArray?
) {
TODO("Not yet implemented")
}
}

View File

@@ -127,9 +127,6 @@ fun FeedScreen(
is FeedListUIState.Loading -> {
LoadingDataIndicator()
LaunchedEffect(true) {
feedListViewModel.loadNostrFeed()
}
}
FeedListUIState.Error -> {
Text("Something went wrong")

View File

@@ -125,66 +125,6 @@ fun SearchScreen(
"Zinfandel",
)
val recentSearches = remember {
mutableStateListOf(
"Alligator",
"Baboon",
"Camel",
"Deer",
"Earthworm",
"Falcon",
"Gazelle",
"Hamster",
"Iguana",
"Jellyfish",
"Koala",
"Leopard",
"Meerkat",
"Nilgai",
"Octopus",
"Panda",
"Quokka",
"Panda",
"Rabbit",
"Scorpion",
"Tarantula",
"Uakari",
"Viper",
"Walrus",
"Xenops",
"Yak",
"Zebra",
)
}
val profileSearches = listOf(
"Afghanistan",
"Bahamas",
"Cambodia",
"Denmark",
"Egypt",
"Finland",
"Gabon",
"Haiti",
"Iran",
"Jamaica",
"Kazakhstan",
"Lesotho",
"Malawi",
"Namibia",
"Oman",
"Pakistan",
"Qatar",
"Russia",
"Samoa",
"Senegal",
"Tanzania",
"Uganda",
"Venezuela",
"Yemen",
"Zambia"
)
val textFieldState = rememberTextFieldState()
// Controls expansion state of the search bar
var expanded by rememberSaveable { mutableStateOf(false) }
@@ -363,10 +303,6 @@ fun SearchScreen(
}
}
} else {
LazyColumn {
// Display search results in a scrollable column
item {
@@ -430,21 +366,26 @@ fun SearchScreen(
}
}
item {
Column(
modifier = Modifier.padding(10.dp),
) {
Text(
text = "Recents",
style = MaterialTheme.typography.titleMedium
)
if (searchViewModel.recentSearches.isNotEmpty()) {
Column(
modifier = Modifier.padding(10.dp),
) {
Text(
text = "Recents",
style = MaterialTheme.typography.titleMedium
)
}
}
}
itemsIndexed(recentSearches) { index, item ->
items(
items = searchViewModel.recentSearches,
key = { recentSearch -> recentSearch.query }
) { recentSearch ->
ListItem(
colors = ListItemDefaults.colors(
containerColor = Color.Transparent
),
headlineContent = { Text(item) },
headlineContent = { Text(recentSearch.query) },
leadingContent = {
Icon(
Icons.Default.YoutubeSearchedFor,
@@ -455,7 +396,7 @@ fun SearchScreen(
.clickable {
onNavigateToSearchResult.invoke(
SearchResultRoute(
query = item
query = recentSearch.query
)
)
}
@@ -463,7 +404,7 @@ fun SearchScreen(
trailingContent = {
IconButton(
onClick = {
recentSearches.removeAt(index)
searchViewModel.removeRecentSearch(recentSearch)
}
) {
Icon(

View File

@@ -37,20 +37,14 @@ class FeedListViewModel(
val isSynchronizationPending: MutableState<Boolean> = mutableStateOf(false)
init {
observeSynchronizeNostrEventRequestByPurposeAndStatusCount()
observeLocalNostrFeed()
}
fun retryLoad() {
feedListUIState = FeedListUIState.Loading
loadNostrFeed()
}
fun loadNostrFeed() {
logger.d("init")
scheduleSynchronization()
observeLocalNostrFeed()
observeSynchronizeNostrEventRequestByPurposeAndStatusCount()
}
fun observeLocalNostrFeed() {
logger.d("observeLocalNostrFeed")
viewModelScope.launch(Dispatchers.IO) {
// Get contact list and sync feed...
@@ -70,6 +64,7 @@ class FeedListViewModel(
}
fun scheduleSynchronization() {
logger.d("scheduleSynchronization")
viewModelScope.launch(Dispatchers.IO) {
// Sync Notifications... might want to also run this in the background
nostrRepository.queueSynchronizeNostrEvent(
@@ -98,6 +93,7 @@ class FeedListViewModel(
}
fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount() {
logger.d("observeSynchronizeNostrEventRequestByPurposeAndStatusCount")
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.observeSynchronizeNostrEventRequestByPurposeAndStatusCount("feed").collect { count ->
logger.d("Pending/Processing Synchronization Request: $count")

View File

@@ -1,15 +1,13 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.RecentSearch
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.intermdiate.LocalProfile
import ac.aux.compose.database.model.types.SynchronizationFilter
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.repository.SearchRepository
import ac.aux.compose.ui.view.state.SearchUIState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -20,7 +18,6 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -42,32 +39,21 @@ class SearchViewModel(
var searchableProfiles = mutableStateListOf<Profile>()
private set
var recentSearches = mutableStateListOf<RecentSearch>()
private set
init {
loadSearchableProfiles()
loadRecentSearches()
}
fun performSearch(searchQuery: String) {
viewModelScope.launch(Dispatchers.IO) {
// Sync Notifications... might want to also run this in the background
searchRepository.submitSearch(
Relays.eventPublishRelaySet.map { normalizedRelay ->
SynchronizeNostrEventRequest(
purpose = "search",
synchronizationFilters = arrayOf(
SynchronizationFilter(
kinds = arrayOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
ReactionEvent.KIND,
LnZapEvent.KIND,
),
search = "shoes",
limit = 50
)
),
relayURL = normalizedRelay.url
)
}
nostrRepository.saveSearchQuery(
searchQuery,
// TODO: synchronizationFilterArray
)
// TODO: Run search on local db for suggestions...
}
}
@@ -79,6 +65,21 @@ class SearchViewModel(
}
}
fun loadRecentSearches() {
viewModelScope.launch(Dispatchers.IO) {
recentSearches.addAll(
nostrRepository.getRecentSearches()
)
}
}
fun removeRecentSearch(recentSearch: RecentSearch) {
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.removeRecentSearch(recentSearch)
recentSearches.remove(recentSearch)
}
}
companion object {
const val TAG = "SearchViewModel"