Add chat database models

This commit is contained in:
Kgothatso Ngako
2026-05-18 16:19:36 +02:00
parent e09eb664c0
commit b604751ab4
14 changed files with 3195 additions and 1 deletions

View File

@@ -3,7 +3,12 @@ package ac.cord.auxiliary.compose.database
import ac.cord.auxiliary.compose.database.converters.AuxConverters
import ac.cord.auxiliary.compose.database.dao.BroadcastNostrEventReceiptDao
import ac.cord.auxiliary.compose.database.dao.BroadcastNostrEventRequestDao
import ac.cord.auxiliary.compose.database.dao.ChatMessageDao
import ac.cord.auxiliary.compose.database.dao.ChatRoomDao
import ac.cord.auxiliary.compose.database.dao.ConnectionDao
import ac.cord.auxiliary.compose.database.dao.GiftWrapMessageDao
import ac.cord.auxiliary.compose.database.dao.GiftWrapSealDao
import ac.cord.auxiliary.compose.database.dao.GiftWrapUnsignedDao
import ac.cord.auxiliary.compose.database.dao.InReplyToRelationDao
import ac.cord.auxiliary.compose.database.dao.MentionDao
import ac.cord.auxiliary.compose.database.dao.NegentropySynchronizeRequestDao
@@ -24,7 +29,12 @@ import ac.cord.auxiliary.compose.database.dao.UnsignedNostrEventDao
import ac.cord.auxiliary.compose.database.dao.ZapDao
import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventReceipt
import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventRequest
import ac.cord.auxiliary.compose.database.model.ChatMessage
import ac.cord.auxiliary.compose.database.model.ChatRoom
import ac.cord.auxiliary.compose.database.model.Connection
import ac.cord.auxiliary.compose.database.model.GiftWrapMessage
import ac.cord.auxiliary.compose.database.model.GiftWrapSeal
import ac.cord.auxiliary.compose.database.model.GiftWrapUnsigned
import ac.cord.auxiliary.compose.database.model.InReplyToRelation
import ac.cord.auxiliary.compose.database.model.Mention
import ac.cord.auxiliary.compose.database.model.NegentropySynchronizeRequest
@@ -42,6 +52,7 @@ import ac.cord.auxiliary.compose.database.model.SynchronizeNostrEventRequest
import ac.cord.auxiliary.compose.database.model.SynchronizeNostrEventResult
import ac.cord.auxiliary.compose.database.model.UnsignedNostrEvent
import ac.cord.auxiliary.compose.database.model.Zap
import androidx.room3.AutoMigration
import androidx.room3.Database
import androidx.room3.RoomDatabase
import androidx.room3.TypeConverters
@@ -56,7 +67,12 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
BroadcastNostrEventReceipt::class,
BroadcastNostrEventRequest::class,
Connection::class,
ChatMessage::class,
ChatRoom::class,
InReplyToRelation::class,
GiftWrapMessage::class,
GiftWrapSeal::class,
GiftWrapUnsigned::class,
Mention::class,
NegentropySynchronizeRequest::class,
NegentropySynchronizeResult::class,
@@ -74,14 +90,31 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 1
version = 2,
autoMigrations = [
AutoMigration(
from = 1,
to = 2
)
]
)
@TypeConverters(AuxConverters::class)
abstract class AuxDatabase: RoomDatabase() {
abstract fun broadcastNostrEventReceiptDao(): BroadcastNostrEventReceiptDao
abstract fun broadcastNostrEventRequestDao(): BroadcastNostrEventRequestDao
abstract fun chatMessageDao(): ChatMessageDao
abstract fun chatRoomDao(): ChatRoomDao
abstract fun connectionDao(): ConnectionDao
abstract fun giftWrapMessageDao(): GiftWrapMessageDao
abstract fun giftWrapSealDao(): GiftWrapSealDao
abstract fun giftWrapUnsignedDao(): GiftWrapUnsignedDao
abstract fun inReplyToRelationDao(): InReplyToRelationDao
abstract fun mentionDao(): MentionDao

View File

@@ -0,0 +1,17 @@
package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.ChatMessage
import ac.cord.auxiliary.compose.database.model.Relay
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface ChatMessageDao {
@Query("SELECT * FROM ChatMessage WHERE chatRoomId = :chatRoomId")
fun observeChatMessagesByChatRoomId(chatRoomId: String): Flow<List<ChatMessage>>
@Upsert
suspend fun upsert(chatMessage: ChatMessage)
}

View File

@@ -0,0 +1,20 @@
package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.ChatRoom
import ac.cord.auxiliary.compose.database.model.Relay
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import fr.acinq.bitcoin.PublicKey
import kotlinx.coroutines.flow.Flow
@Dao
interface ChatRoomDao {
@Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey")
fun observeChatRoomByUserPublicKey(userPublicKey: String): Flow<List<ChatRoom>>
@Upsert
suspend fun upsert(chatRoom: ChatRoom)
}

View File

@@ -0,0 +1,18 @@
package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.GiftWrapMessage
import ac.cord.auxiliary.compose.database.model.Relay
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface GiftWrapMessageDao {
@Query("SELECT * FROM GiftWrapMessage")
fun getAllGiftWrapMessages(): List<GiftWrapMessage>
@Upsert
suspend fun upsert(giftWrapMessage: GiftWrapMessage)
}

View File

@@ -0,0 +1,18 @@
package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.GiftWrapSeal
import ac.cord.auxiliary.compose.database.model.Relay
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface GiftWrapSealDao {
@Query("SELECT * FROM GiftWrapSeal")
fun getAllGiftWrapSeals(): List<GiftWrapSeal>
@Upsert
suspend fun upsert(giftWrapSeal: GiftWrapSeal)
}

View File

@@ -0,0 +1,19 @@
package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.GiftWrapUnsigned
import ac.cord.auxiliary.compose.database.model.Relay
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface GiftWrapUnsignedDao {
@Query("SELECT * FROM GiftWrapUnsigned")
fun getAllGiftWrapUnsigned(): List<GiftWrapUnsigned>
@Upsert
suspend fun upsert(giftWrapUnsigned: GiftWrapUnsigned)
}

View File

@@ -0,0 +1,51 @@
package ac.cord.auxiliary.compose.database.model
import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity
import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity
import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity
import ac.cord.auxiliary.compose.database.model.traits.UserViewableEntity
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = GiftWrapUnsigned::class,
parentColumns = ["id"],
childColumns = ["giftWrapUnsignedId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class ChatMessage(
@PrimaryKey
val id: HexKey,
val senderPublicKey: String,
val isUserMessage: Boolean,
val chatRoomId: String,
val giftWrapUnsignedId: String,
val replyToEventId: String?,
val quotedEventId: String?,
val content: String,
override val createdAt: Instant,
override val updatedAt: Instant,
override val savedAt: Instant,
override val viewedAt: Instant?,
override val deletedAt: Instant?, // Who this message should be seen by (used to derive conversationId)
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
}

View File

@@ -0,0 +1,79 @@
package ac.cord.auxiliary.compose.database.model
import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity
import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity
import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity
import ac.cord.auxiliary.compose.database.model.traits.UserViewableEntity
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["publicKey"],
childColumns = ["userPublicKey"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = GiftWrapUnsigned::class,
parentColumns = ["id"],
childColumns = ["initialGiftWrapUnsignedId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class ChatRoom(
/**
* Derived from the participants...
*
* can aggregate them into one id like musig(participants...)
*/
@PrimaryKey
val id: String,
/**
* Logged in user PublicKey... should help us have multiple user support...
*/
val userPublicKey: HexKey,
/**
* Optional retrieved from tags...
*
* Any member can change the topic by simply submitting a new subject to an existing pubkey + p tags room.
*
* There is no need to send subject in every message. The newest subject in the chat room is the subject of the conversation.
*/
val subject: String?,
/**
* Should probably be the human-readable name of whichever participant is not the user...
*/
val name: String?,
/**
* Derived from kind14/15 tags
* [
* ["p", "<receiver-1-pubkey>", "<relay-url>"],
* ["p", "<receiver-2-pubkey>", "<relay-url>"],
* // rest of tags...
* ]
*
* TODO: Might want to add a relation for this...
*/
// TODO: Add a LastChatMessageRelation
// val lastChatMessageId: String? = null,
// Might want to focus on the other last messages...
val initialGiftWrapUnsignedId: String,
override val createdAt: Instant,
override val updatedAt: Instant,
override val savedAt: Instant,
override val viewedAt: Instant?,
override val deletedAt: Instant?,
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
}

View File

@@ -0,0 +1,103 @@
package ac.cord.auxiliary.compose.database.model
import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity
import ac.cord.auxiliary.compose.database.model.traits.NostrEventEntity
import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity
import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity
import ac.cord.auxiliary.compose.database.model.traits.UserViewableEntity
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = NostrEvent::class,
parentColumns = ["id"],
childColumns = ["nostrEventId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class GiftWrapMessage(
@PrimaryKey
val id: HexKey,
/**
* Random PublicKey (should it be derived from randomPrivateKey?)
*/
val publicKey: String,
val kind: Kind = 1059,
/**
* [
* ["p", receiverPublicKey, "<relay-url>"] // receiver
* ]
*/
val tags: Array<Array<String>>,
/**
*
* encrypt(GiftWrapSeal, randomPrivateKey, receiverPublicKey)
*/
val content: String,
/**
* Signed using the randomPrivateKey used to encrypt the GiftWrapSeal
*/
val signature: String,
/**
* What gets broadcasts?
*/
override val nostrEventId: String,
/**
* randomTimeUpTo2DaysInThePast
*/
override val createdAt: Instant,
override val updatedAt: Instant,
override val savedAt: Instant,
override val viewedAt: Instant?,
override val deletedAt: Instant?, // Who this message should be seen by (used to derive conversationId)
): NostrEventEntity, TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as GiftWrapMessage
if (kind != other.kind) return false
if (id != other.id) return false
if (publicKey != other.publicKey) return false
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (signature != other.signature) return false
if (nostrEventId != other.nostrEventId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (savedAt != other.savedAt) return false
if (viewedAt != other.viewedAt) return false
if (deletedAt != other.deletedAt) return false
return true
}
override fun hashCode(): Int {
var result = kind
result = 31 * result + id.hashCode()
result = 31 * result + publicKey.hashCode()
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + signature.hashCode()
result = 31 * result + nostrEventId.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + savedAt.hashCode()
result = 31 * result + (viewedAt?.hashCode() ?: 0)
result = 31 * result + (deletedAt?.hashCode() ?: 0)
return result
}
}

View File

@@ -0,0 +1,102 @@
package ac.cord.auxiliary.compose.database.model
import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity
import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity
import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity
import ac.cord.auxiliary.compose.database.model.traits.UserViewableEntity
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = GiftWrapMessage::class,
parentColumns = ["id"],
childColumns = ["giftWrapMessageId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class GiftWrapSeal(
@PrimaryKey
val id: HexKey,
/**
* senderPublicKey
*/
val publicKey: String,
val kind: Kind = 13,
/**
* No tags
*/
val tags: Array<Array<String>> = emptyArray(),
/**
*
* nip44Encrypt(unsignedKind14, senderPrivateKey, receiverPublicKey)
*/
val content: String,
/**
* signed by senderPrivateKey
*/
val signature: String,
/**
* Associated GiftWrapMessage
*/
val giftWrapMessageId: String? = null,
/**
* randomTimeUpTo2DaysInThePast()
*/
override val createdAt: Instant,
override val updatedAt: Instant,
override val savedAt: Instant,
override val viewedAt: Instant?,
override val deletedAt: Instant?, // Who this message should be seen by (used to derive conversationId)
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as GiftWrapSeal
if (kind != other.kind) return false
if (id != other.id) return false
if (publicKey != other.publicKey) return false
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (signature != other.signature) return false
if (giftWrapMessageId != other.giftWrapMessageId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (savedAt != other.savedAt) return false
if (viewedAt != other.viewedAt) return false
if (deletedAt != other.deletedAt) return false
return true
}
override fun hashCode(): Int {
var result = kind
result = 31 * result + id.hashCode()
result = 31 * result + publicKey.hashCode()
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + signature.hashCode()
result = 31 * result + (giftWrapMessageId?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + savedAt.hashCode()
result = 31 * result + (viewedAt?.hashCode() ?: 0)
result = 31 * result + (deletedAt?.hashCode() ?: 0)
return result
}
}

View File

@@ -0,0 +1,110 @@
package ac.cord.auxiliary.compose.database.model
import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity
import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity
import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity
import ac.cord.auxiliary.compose.database.model.traits.UserViewableEntity
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = GiftWrapSeal::class,
parentColumns = ["id"],
childColumns = ["giftWrapSealId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class GiftWrapUnsigned(
@PrimaryKey
val id: HexKey,
/**
* Sender Public Key
*/
val publicKey: String,
/**
* Chat Message Kind
*/
val kind: Kind = 14,
/**
* Tags for the unsigned giftwrap
*
* [
* ["p", "<receiver-1-pubkey>", "<relay-url>"],
* ["p", "<receiver-2-pubkey>", "<relay-url>"],
* ["e", "<kind-14-id>", "<relay-url>"] // if this is a reply
* ["subject", "<conversation-title>"],
* // rest of tags...
* ]
*
* q tags MAY be used when citing events in the .content with NIP-21.
*/
val tags: Array<Array<String>>,
/**
* content MUST be plain text. Fields id and created_at are required.
*/
val content: String,
val quotedEventId: String?,
/**
* Associated GiftWrapSeal
*/
val giftWrapSealId: String? = null,
/**
* Current time
*/
override val createdAt: Instant,
override val updatedAt: Instant,
override val savedAt: Instant,
override val viewedAt: Instant?,
override val deletedAt: Instant?, // Who this message should be seen by (used to derive conversationId)
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as GiftWrapUnsigned
if (kind != other.kind) return false
if (id != other.id) return false
if (publicKey != other.publicKey) return false
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (quotedEventId != other.quotedEventId) return false
if (giftWrapSealId != other.giftWrapSealId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
if (savedAt != other.savedAt) return false
if (viewedAt != other.viewedAt) return false
if (deletedAt != other.deletedAt) return false
return true
}
override fun hashCode(): Int {
var result = kind
result = 31 * result + id.hashCode()
result = 31 * result + publicKey.hashCode()
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + (quotedEventId?.hashCode() ?: 0)
result = 31 * result + (giftWrapSealId?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
result = 31 * result + savedAt.hashCode()
result = 31 * result + (viewedAt?.hashCode() ?: 0)
result = 31 * result + (deletedAt?.hashCode() ?: 0)
return result
}
}

View File

@@ -0,0 +1,52 @@
package ac.cord.auxiliary.compose.database.model
import ac.cord.auxiliary.compose.database.model.traits.BroadcastableEntity
import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity
import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity
import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Derived from kind14/15 tags
* [
* ["p", "<receiver-1-pubkey>", "<relay-url>"],
* ["p", "<receiver-2-pubkey>", "<relay-url>"],
* // rest of tags...
* ]
*
* TODO: Might want to add a relation for this...
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["publicKey"],
childColumns = ["participantPublicKey"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
),
]
)
data class Participant(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val participantPublicKey: HexKey,
val chatRoomId: String,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = Clock.System.now(),
override val savedAt: Instant = Clock.System.now(),
override val deletedAt: Instant? = null,
override val broadcastedAt: Instant? = null,
): TimestampedEntity, LocalStoreEntity, BroadcastableEntity, SoftDeletableEntity

View File

@@ -0,0 +1,16 @@
package ac.cord.auxiliary.compose.database.model.intermdiate
import ac.cord.auxiliary.compose.database.model.Participant
import ac.cord.auxiliary.compose.database.model.Profile
import androidx.room3.Embedded
import androidx.room3.Relation
data class LocalParticipant(
@Embedded val participant: Participant,
@Relation(
parentColumn = "participantPublicKey",
entityColumn = "publicKey"
)
val profile: Profile?,
)