Handle notifications and syncing

This commit is contained in:
Kgothatso Ngako
2026-03-31 14:29:11 +02:00
parent 2beed5e18a
commit 3cefeae5c1
12 changed files with 196 additions and 71 deletions

View File

@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ba8dabe94c38a3823b4803dc0a6051ca",
"identityHash": "0e3d4940d3b045a7e68c2f0b98642ab0",
"entities": [
{
"tableName": "BroadcastNostrEventReceipt",
@@ -735,7 +735,7 @@
},
{
"tableName": "Repost",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostedPostId` TEXT, `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(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedPostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostedPostId` 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(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedPostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
@@ -746,7 +746,8 @@
{
"fieldPath": "repostedPostId",
"columnName": "repostedPostId",
"affinity": "TEXT"
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
@@ -873,7 +874,7 @@
},
{
"tableName": "SynchronizeNostrEventRequest",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `filters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
@@ -894,8 +895,8 @@
"notNull": true
},
{
"fieldPath": "filters",
"columnName": "filters",
"fieldPath": "synchronizationFilters",
"columnName": "synchronizationFilters",
"affinity": "TEXT",
"notNull": true
},
@@ -1306,7 +1307,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, 'ba8dabe94c38a3823b4803dc0a6051ca')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0e3d4940d3b045a7e68c2f0b98642ab0')"
]
}
}

View File

@@ -1,15 +1,13 @@
package ac.aux.compose.database.converters
import ac.aux.compose.database.model.typealiases.FilterArray
import ac.aux.compose.database.model.typealiases.SynchronizationFilterArray
import ac.aux.compose.database.model.typealiases.HexArray
import ac.aux.compose.database.model.typealiases.KindArray
import androidx.room.TypeConverter
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlin.time.Instant
class AuxConverters {
@@ -79,13 +77,9 @@ class AuxConverters {
}
@TypeConverter
fun fromFilterArray(value: FilterArray?): String? = try {
return value?.let { filterArray ->
Json.encodeToString(
filterArray.map { filter ->
OptimizedJsonMapper.toJson(filter)
}
)
fun fromFilterArray(value: SynchronizationFilterArray?): String? = try {
return value?.let {
Json.encodeToString(value)
}
} catch (e: Throwable) {
logger.e("Failed to convert from FilterArray $value", e)
@@ -93,9 +87,9 @@ class AuxConverters {
}
@TypeConverter
fun toFilterArray(value: String?): FilterArray? = try {
fun toFilterArray(value: String?): SynchronizationFilterArray? = try {
return value?.let {
Json.decodeFromString<FilterArray>(it)
Json.decodeFromString<SynchronizationFilterArray>(it)
}
} catch (e: Throwable) {
logger.e("Failed to convert to FilterArray $value", e)

View File

@@ -6,16 +6,13 @@ 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.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.SynchronizeNostrEventResult
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.types.SynchronizationFilter
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Relation
import androidx.room.Transaction
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.delay
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlin.time.Clock
@Dao
@@ -96,7 +93,7 @@ abstract class NostrDao(
)
val placeHolderProfileNostrEvent = NostrEvent(
id = placeHolderProfile.publicKey,
kind = 30024,
kind = DraftWrapEvent.KIND,
pubKey = placeHolderProfile.publicKey,
content = placeHolderProfile.publicKey,
createdAt = Clock.System.now(),
@@ -110,10 +107,10 @@ abstract class NostrDao(
logger.i("Sync Profile with PubKey: ${nostrEvent.pubKey}")
database.synchronizeNostrEventRequestDao().upsert(
SynchronizeNostrEventRequest(
filters = arrayOf(
Filter(
authors = listOf(nostrEvent.pubKey),
kinds = listOf(MetadataEvent.KIND)
synchronizationFilters = arrayOf(
SynchronizationFilter(
authors = arrayOf(nostrEvent.pubKey),
kinds = arrayOf(MetadataEvent.KIND)
)
),
relayURL = synchronizationRelayURL
@@ -155,9 +152,9 @@ abstract class NostrDao(
synchronizationRelayURLs.forEach { synchronizationRelayURL ->
database.synchronizeNostrEventRequestDao().upsert(
SynchronizeNostrEventRequest(
filters = arrayOf(
Filter(
ids = listOf(post.replyToId)
synchronizationFilters = arrayOf(
SynchronizationFilter(
ids = arrayOf(post.replyToId)
)
),
relayURL = synchronizationRelayURL
@@ -184,9 +181,9 @@ abstract class NostrDao(
synchronizationRelayURLs.forEach { synchronizationRelayURL ->
database.synchronizeNostrEventRequestDao().upsert(
SynchronizeNostrEventRequest(
filters = arrayOf(
Filter(
ids = listOf(post.repostId)
synchronizationFilters = arrayOf(
SynchronizationFilter(
ids = arrayOf(post.repostId)
)
),
relayURL = synchronizationRelayURL
@@ -207,10 +204,67 @@ abstract class NostrDao(
}
nostrEvent.toRepost()?.let { repost ->
database.repostDao().upsert(repost)
// Find or create placeHolder note that is being reposted
val repostedPost = database.postDao().getPostById(repost.repostedPostId)
if (repostedPost == null) {
// Persist PlaceHolder and sync
database.postDao().insert(
Post(
id = repost.repostedPostId,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
content = repost.repostedPostId,
profilePublicKey = nostrEvent.pubKey // Will get overwriting by sync,
)
)
// Request a sync for the post being replied to
synchronizationRelayURLs.forEach { synchronizationRelayURL ->
database.synchronizeNostrEventRequestDao().upsert(
SynchronizeNostrEventRequest(
synchronizationFilters = arrayOf(
SynchronizationFilter(
ids = arrayOf(repost.repostedPostId)
)
),
relayURL = synchronizationRelayURL
)
)
}
}
}
nostrEvent.toZap()?.let { zap ->
// Find or create placeHolder note that is being reposted
val zappedPost = database.postDao().getPostById(zap.postId)
if (zappedPost == null) {
// Persist PlaceHolder and sync
database.postDao().insert(
Post(
id = zap.postId,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
content = zap.postId,
profilePublicKey = nostrEvent.pubKey // Will get overwriting by sync,
)
)
// Request a sync for the post being replied to
synchronizationRelayURLs.forEach { synchronizationRelayURL ->
database.synchronizeNostrEventRequestDao().upsert(
SynchronizeNostrEventRequest(
synchronizationFilters = arrayOf(
SynchronizationFilter(
ids = arrayOf(zap.postId)
)
),
relayURL = synchronizationRelayURL
)
)
}
}
// TODO: Placeholder for all the other things...
database.zapDao().upsert(zap)
}
}

View File

@@ -3,6 +3,7 @@ package ac.aux.compose.database.dao
import ac.aux.compose.database.model.NostrEvent
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.IGNORE
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@@ -17,4 +18,9 @@ interface NostrEventDao {
@Upsert
suspend fun upsert(nostrEvent: NostrEvent)
@Insert(
onConflict = IGNORE
)
suspend fun insert(nostrEvent: NostrEvent)
}

View File

@@ -47,7 +47,7 @@ import kotlin.time.Instant
data class Repost(
@PrimaryKey
val id: HexKey,
val repostedPostId: HexKey? = null,
val repostedPostId: HexKey,
val content: String,
override val nostrEventId: HexKey, // Might be a replaceable nostr event?

View File

@@ -3,15 +3,12 @@ package ac.aux.compose.database.model
import ac.aux.compose.database.model.traits.OptionalNostrEventEntity
import ac.aux.compose.database.model.traits.TimestampedEntity
import ac.aux.compose.database.model.traits.UnsignedNostrEventEntity
import ac.aux.compose.database.model.typealiases.FilterArray
import ac.aux.compose.database.model.typealiases.HexArray
import ac.aux.compose.database.model.typealiases.KindArray
import ac.aux.compose.database.model.typealiases.SynchronizationFilterArray
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
@@ -44,7 +41,7 @@ data class SynchronizeNostrEventRequest(
val status: String = "pending",
val relayURL: String,
val filters: FilterArray,
val synchronizationFilters: SynchronizationFilterArray,
override val nostrEventId: HexKey? = null,
override val unsignedNostrEventId: Long? = null,
@@ -61,7 +58,7 @@ data class SynchronizeNostrEventRequest(
if (id != other.id) return false
if (status != other.status) return false
if (relayURL != other.relayURL) return false
if (!filters.contentEquals(other.filters)) return false
if (!synchronizationFilters.contentEquals(other.synchronizationFilters)) return false
if (nostrEventId != other.nostrEventId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
@@ -74,7 +71,7 @@ data class SynchronizeNostrEventRequest(
result = 31 * result + id.hashCode()
result = 31 * result + status.hashCode()
result = 31 * result + relayURL.hashCode()
result = 31 * result + filters.contentHashCode()
result = 31 * result + synchronizationFilters.contentHashCode()
result = 31 * result + (nostrEventId?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()

View File

@@ -1,5 +0,0 @@
package ac.aux.compose.database.model.typealiases
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
typealias FilterArray = Array<Filter>

View File

@@ -0,0 +1,5 @@
package ac.aux.compose.database.model.typealiases
import ac.aux.compose.database.model.types.SynchronizationFilter
typealias SynchronizationFilterArray = Array<SynchronizationFilter>

View File

@@ -0,0 +1,51 @@
package ac.aux.compose.database.model.types
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.serialization.Serializable
import kotlin.time.Instant
@Serializable
data class SynchronizationFilter(
val ids: Array<HexKey>? = null,
val authors: Array<HexKey>? = null,
val kinds: Array<Kind>? = null,
val tags: Map<String, List<String>>? = null,
val tagsAll: Map<String, List<String>>? = null,
val since: Instant? = null,
val until: Instant? = null,
val limit: Int? = null,
val search: String? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as SynchronizationFilter
if (since != other.since) return false
if (until != other.until) return false
if (limit != other.limit) return false
if (!ids.contentEquals(other.ids)) return false
if (!authors.contentEquals(other.authors)) return false
if (!kinds.contentEquals(other.kinds)) return false
if (tags != other.tags) return false
if (tagsAll != other.tagsAll) return false
if (search != other.search) return false
return true
}
override fun hashCode(): Int {
var result = since?.hashCode() ?: 0
result = 31 * result + (until?.hashCode() ?: 0)
result = 31 * result + (limit ?: 0)
result = 31 * result + (ids?.contentHashCode() ?: 0)
result = 31 * result + (authors?.contentHashCode() ?: 0)
result = 31 * result + (kinds?.contentHashCode() ?: 0)
result = 31 * result + (tags?.hashCode() ?: 0)
result = 31 * result + (tagsAll?.hashCode() ?: 0)
result = 31 * result + (search?.hashCode() ?: 0)
return result
}
}

View File

@@ -1,6 +1,9 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
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.ui.view.state.FeedListUIState
import androidx.compose.runtime.getValue
@@ -12,7 +15,6 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -36,17 +38,30 @@ class FeedListViewModel(
fun loadNostrFeed() {
viewModelScope.launch(Dispatchers.IO) {
Filter(
kinds = listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
ReactionEvent.KIND,
LnZapEvent.KIND,
),
tags = mapOf(
Pair("#p", listOf(SeedManager.activePublicKey().toHexKey()))
)
nostrRepository.queueSynchronizeNostrEvent(
Relays.eventPublishRelaySet.map { normalizedRelay ->
SynchronizeNostrEventRequest(
synchronizationFilters = arrayOf(
SynchronizationFilter(
kinds = arrayOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
ReactionEvent.KIND,
LnZapEvent.KIND,
),
tags = mapOf(
Pair("p", listOf(SeedManager.activePublicKey().toHexKey()))
),
limit = 50
)
),
relayURL = normalizedRelay.url
)
}
)
val localProfile = nostrRepository.observeProfile(SeedManager.activePublicKey().toHexKey()).firstOrNull()
if (localProfile?.profile == null) {

View File

@@ -147,7 +147,19 @@ class NavigationViewModel(
logger.i("synchronizeNostrEventRequest: $synchronizeNostrEventRequest")
val reqCommand = ReqCmd(
subId = synchronizeNostrEventRequest.id,
filters = synchronizeNostrEventRequest.filters.toList()
filters = synchronizeNostrEventRequest.synchronizationFilters.map { synchronizationFilter ->
Filter(
ids = synchronizationFilter.ids?.toList(),
authors = synchronizationFilter.authors?.toList(),
kinds = synchronizationFilter.kinds?.toList(),
tags = synchronizationFilter.tags,
tagsAll = synchronizationFilter.tagsAll,
since = synchronizationFilter.since?.epochSeconds,
until = synchronizationFilter.until?.epochSeconds,
limit = synchronizationFilter.limit,
search = synchronizationFilter.search
)
}
)
scope.launch {

View File

@@ -1,10 +1,9 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.types.SynchronizationFilter
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.SignInToProfileUIState
import ac.aux.compose.ui.view.state.form.SignInToProfileFormState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -15,10 +14,6 @@ import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.delay
@@ -60,10 +55,10 @@ class UnqueuedProfileSynchronizationViewModel(
nostrRepository.queueSynchronizeNostrEvent(
Relays.eventPublishRelaySet.map { normalizedRelayUrl ->
SynchronizeNostrEventRequest(
filters = arrayOf(
Filter(
authors = listOf(profilePublicKey),
kinds = listOf(MetadataEvent.KIND)
synchronizationFilters = arrayOf(
SynchronizationFilter(
authors = arrayOf(profilePublicKey),
kinds = arrayOf(MetadataEvent.KIND)
)
),
unsignedNostrEventId = unsignedNostrEventId,