From 36facaf6a7290190bf68d1326febc3f06c2e4973 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Thu, 2 Apr 2026 00:18:29 +0200 Subject: [PATCH] Show recent searches from local input --- .../1.json | 39 +++++++- .../ac/aux/compose/database/AuxDatabase.kt | 5 + .../compose/database/dao/RecentSearchDao.kt | 25 +++++ .../compose/database/model/RecentSearch.kt | 41 +++++++++ .../repository/DatabaseNostrRepository.kt | 21 ++++- .../aux/compose/repository/NostrRepository.kt | 20 +++- .../aux/compose/ui/composable/FeedScreen.kt | 3 - .../aux/compose/ui/composable/SearchScreen.kt | 91 ++++--------------- .../ui/view/model/FeedListViewModel.kt | 16 ++-- .../compose/ui/view/model/SearchViewModel.kt | 49 +++++----- 10 files changed, 192 insertions(+), 118 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RecentSearchDao.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/RecentSearch.kt diff --git a/composeApp/schemas/ac.aux.compose.database.AuxDatabase/1.json b/composeApp/schemas/ac.aux.compose.database.AuxDatabase/1.json index 3f2e89d0..82c63f10 100644 --- a/composeApp/schemas/ac.aux.compose.database.AuxDatabase/1.json +++ b/composeApp/schemas/ac.aux.compose.database.AuxDatabase/1.json @@ -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')" ] } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt index 54834d2d..368f2923 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RecentSearchDao.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RecentSearchDao.kt new file mode 100644 index 00000000..be736588 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RecentSearchDao.kt @@ -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 + + @Query("SELECT * FROM RecentSearch ORDER BY createdAt DESC LIMIT :limit ") + fun getAllRecentSearches(limit: Int = 21): List + + @Delete + fun deleteRecentSearch(recentSearch: RecentSearch) + + @Upsert + suspend fun upsert(recentSearch: RecentSearch) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/RecentSearch.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/RecentSearch.kt new file mode 100644 index 00000000..0c837743 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/model/RecentSearch.kt @@ -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 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt index 7a72ed78..b6dc826f 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt @@ -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 { - TODO("Not yet implemented") + override suspend fun getRecentSearches(): List { + 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 + ) + ) + } } diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt index d6ac8f7f..317a6651 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt @@ -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 - suspend fun recentSearches(): List + suspend fun getRecentSearches(): List + 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 { + override suspend fun getRecentSearches(): List { + 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") } } diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/FeedScreen.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/FeedScreen.kt index 10bdace4..35d866ea 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/FeedScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/FeedScreen.kt @@ -127,9 +127,6 @@ fun FeedScreen( is FeedListUIState.Loading -> { LoadingDataIndicator() - LaunchedEffect(true) { - feedListViewModel.loadNostrFeed() - } } FeedListUIState.Error -> { Text("Something went wrong") diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt index 88a9ce82..e2972463 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SearchScreen.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/FeedListViewModel.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/FeedListViewModel.kt index 0ecbe462..21c836d2 100755 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/FeedListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/FeedListViewModel.kt @@ -37,20 +37,14 @@ class FeedListViewModel( val isSynchronizationPending: MutableState = 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") diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt index 49a67792..b1abb626 100755 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SearchViewModel.kt @@ -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() private set + var recentSearches = mutableStateListOf() + 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"