Make room for a database.

This commit is contained in:
Kgothatso Ngako
2026-03-24 04:47:19 +02:00
parent 3e9566d0e9
commit 23033b0b47
24 changed files with 1335 additions and 42 deletions

View File

@@ -7,4 +7,5 @@ plugins {
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.ksp) apply false
}

View File

@@ -2,12 +2,14 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.androidx.room)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinPluginSerialization)
alias(libs.plugins.ksp)
}
kotlin {
@@ -34,6 +36,7 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.okhttp.coroutines)
implementation(libs.androidx.room.sqlite.wrapper)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
@@ -47,6 +50,11 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("androidx.paging:paging-common:3.5.0-alpha01")
implementation("androidx.paging:paging-compose:3.5.0-alpha01")
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation(libs.navigation.compose)
@@ -98,6 +106,16 @@ android {
dependencies {
debugImplementation(libs.compose.uiTooling)
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
// add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
add("kspJvm", libs.androidx.room.compiler)
// Add any other platform target you use in your project, for example kspDesktop
}
room {
schemaDirectory("$projectDir/schemas")
}
compose.desktop {

View File

@@ -0,0 +1,701 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "9a4e13a505370c8fd3433ef4241cbcc8",
"entities": [
{
"tableName": "NostrEvent",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `broadcastedAt` INTEGER NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pubKey",
"columnName": "pubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "savedAt",
"columnName": "savedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "broadcastedAt",
"columnName": "broadcastedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sig",
"columnName": "sig",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "Post",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `repostId` TEXT, `quote` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `broadcastedAt` INTEGER NOT NULL, `content` TEXT NOT NULL, 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(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nostrEventId",
"columnName": "nostrEventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "replyToId",
"columnName": "replyToId",
"affinity": "TEXT"
},
{
"fieldPath": "repostId",
"columnName": "repostId",
"affinity": "TEXT"
},
{
"fieldPath": "quote",
"columnName": "quote",
"affinity": "TEXT"
},
{
"fieldPath": "profilePublicKey",
"columnName": "profilePublicKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "savedAt",
"columnName": "savedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "broadcastedAt",
"columnName": "broadcastedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Post_profilePublicKey",
"unique": false,
"columnNames": [
"profilePublicKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)"
},
{
"name": "index_Post_nostrEventId",
"unique": false,
"columnNames": [
"nostrEventId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
},
{
"name": "index_Post_replyToId",
"unique": false,
"columnNames": [
"replyToId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)"
},
{
"name": "index_Post_repostId",
"unique": false,
"columnNames": [
"repostId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)"
}
],
"foreignKeys": [
{
"table": "Profile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profilePublicKey"
],
"referencedColumns": [
"publicKey"
]
},
{
"table": "NostrEvent",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"nostrEventId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Post",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"replyToId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Post",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"repostId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Profile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "publicKey",
"columnName": "publicKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nostrEventId",
"columnName": "nostrEventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "userName",
"affinity": "TEXT"
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "picture",
"columnName": "picture",
"affinity": "TEXT"
},
{
"fieldPath": "banner",
"columnName": "banner",
"affinity": "TEXT"
},
{
"fieldPath": "website",
"columnName": "website",
"affinity": "TEXT"
},
{
"fieldPath": "about",
"columnName": "about",
"affinity": "TEXT"
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER"
},
{
"fieldPath": "pronouns",
"columnName": "pronouns",
"affinity": "TEXT"
},
{
"fieldPath": "nip05",
"columnName": "nip05",
"affinity": "TEXT"
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT"
},
{
"fieldPath": "lud06",
"columnName": "lud06",
"affinity": "TEXT"
},
{
"fieldPath": "lud16",
"columnName": "lud16",
"affinity": "TEXT"
},
{
"fieldPath": "twitter",
"columnName": "twitter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"publicKey"
]
},
"indices": [
{
"name": "index_Profile_nostrEventId",
"unique": false,
"columnNames": [
"nostrEventId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
}
],
"foreignKeys": [
{
"table": "NostrEvent",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"nostrEventId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Reaction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, 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(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nostrEventId",
"columnName": "nostrEventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "replyToId",
"columnName": "replyToId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePublicKey",
"columnName": "profilePublicKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Reaction_profilePublicKey",
"unique": false,
"columnNames": [
"profilePublicKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)"
},
{
"name": "index_Reaction_nostrEventId",
"unique": false,
"columnNames": [
"nostrEventId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
},
{
"name": "index_Reaction_replyToId",
"unique": false,
"columnNames": [
"replyToId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)"
}
],
"foreignKeys": [
{
"table": "Profile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profilePublicKey"
],
"referencedColumns": [
"publicKey"
]
},
{
"table": "NostrEvent",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"nostrEventId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Post",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"replyToId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Repost",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `postId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, 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(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nostrEventId",
"columnName": "nostrEventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postId",
"columnName": "postId",
"affinity": "TEXT"
},
{
"fieldPath": "profilePublicKey",
"columnName": "profilePublicKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Repost_profilePublicKey",
"unique": false,
"columnNames": [
"profilePublicKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Repost_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)"
},
{
"name": "index_Repost_nostrEventId",
"unique": false,
"columnNames": [
"nostrEventId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Repost_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
},
{
"name": "index_Repost_postId",
"unique": false,
"columnNames": [
"postId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Repost_postId` ON `${TABLE_NAME}` (`postId`)"
}
],
"foreignKeys": [
{
"table": "Profile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profilePublicKey"
],
"referencedColumns": [
"publicKey"
]
},
{
"table": "NostrEvent",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"nostrEventId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Post",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"postId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Zap",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `message` TEXT, `invoice` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) 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(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nostrEventId",
"columnName": "nostrEventId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postId",
"columnName": "postId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderPublicKey",
"columnName": "senderPublicKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "receiverPublicKey",
"columnName": "receiverPublicKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
},
{
"fieldPath": "invoice",
"columnName": "invoice",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Zap_senderPublicKey",
"unique": false,
"columnNames": [
"senderPublicKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)"
},
{
"name": "index_Zap_nostrEventId",
"unique": false,
"columnNames": [
"nostrEventId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)"
},
{
"name": "index_Zap_postId",
"unique": false,
"columnNames": [
"postId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)"
}
],
"foreignKeys": [
{
"table": "Profile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"senderPublicKey"
],
"referencedColumns": [
"publicKey"
]
},
{
"table": "Profile",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"receiverPublicKey"
],
"referencedColumns": [
"publicKey"
]
},
{
"table": "NostrEvent",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"nostrEventId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Post",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"postId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"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, '9a4e13a505370c8fd3433ef4241cbcc8')"
]
}
}

View File

@@ -0,0 +1,42 @@
package ac.aux.compose.database
import ac.aux.compose.database.converters.Converters
import ac.aux.compose.database.dao.NostrDao
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.RepostDao
import ac.aux.compose.database.dao.ZapDao
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.Repost
import ac.aux.compose.database.model.Zap
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [
NostrEvent::class,
Post::class,
Profile::class,
Reaction::class,
Repost::class,
Zap::class
],
version = 1
)
@TypeConverters(Converters::class)
abstract class AuxDatabase: RoomDatabase() {
abstract fun nostrDao(): NostrDao
abstract fun nostrEventDao(): NostrEventDao
abstract fun postDao(): PostDao
abstract fun profileDao(): ProfileDao
abstract fun reactionDao(): ReactionDao
abstract fun repostDao(): RepostDao
abstract fun zapDao(): ZapDao
}

View File

@@ -0,0 +1,30 @@
package ac.aux.compose.database.converters
import androidx.room.TypeConverter
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import kotlinx.serialization.json.Json
import kotlin.time.Instant
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Instant? {
return value?.let { Instant.fromEpochMilliseconds(it) }
}
@TypeConverter
fun instantToTimestamp(instant: Instant?): Long? {
return instant?.toEpochMilliseconds()
}
@TypeConverter
fun fromTagArray(value: TagArray?): String? {
// Returns null if the value is null, otherwise serializes to JSON string
return value?.let { Json.encodeToString(it) }
}
@TypeConverter
fun toTagArray(value: String?): TagArray? {
// Decodes the JSON string back into the nested array structure
return value?.let { Json.decodeFromString<TagArray>(it) }
}
}

View File

@@ -0,0 +1,18 @@
package ac.aux.compose.database.dao
import ac.aux.compose.database.AuxDatabase
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
import androidx.room.Dao
import androidx.room.Transaction
@Dao
abstract class NostrDao(
private val database: AuxDatabase
) {
@Transaction
open suspend fun insertPostAndNostrEvent(post: Post, nostrEvent: NostrEvent) {
database.nostrEventDao().insert(nostrEvent)
database.postDao().insert(post) // TODO: Make use of the nostrEventId...
}
}

View File

@@ -0,0 +1,19 @@
package ac.aux.compose.database.dao
import ac.aux.compose.database.model.NostrEvent
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface NostrEventDao {
@Query("SELECT * FROM NostrEvent")
fun getAllNostrEvents(): List<NostrEvent>
@Query("SELECT * FROM NostrEvent WHERE id = :id")
fun getNostrEventById(id: String): Flow<NostrEvent?>
@Insert
suspend fun insert(nostrEvent: NostrEvent)
}

View File

@@ -0,0 +1,31 @@
package ac.aux.compose.database.dao
import ac.aux.compose.database.model.Post
import ac.aux.compose.database.model.Profile
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Relation
import kotlinx.coroutines.flow.Flow
@Dao
interface PostDao {
@Query("SELECT * FROM Post")
fun getAllPosts(): List<PostAndProfile>
@Query("SELECT * FROM Post WHERE id = :id")
fun getPostById(id: String): Flow<Post?>
@Insert
suspend fun insert(post: Post)
data class PostAndProfile(
@Embedded val post: Post,
@Relation(
parentColumn = "profilePublicKey",
entityColumn = "publicKey"
)
val profile: Profile
)
}

View File

@@ -0,0 +1,21 @@
package ac.aux.compose.database.dao
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
import ac.aux.compose.database.model.Profile
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ProfileDao {
@Query("SELECT * FROM Profile")
fun getAllPosts(): List<Profile>
@Query("SELECT * FROM Profile WHERE publicKey = :publicKey")
fun getProfileByPublicKey(publicKey: String): Flow<Profile?>
@Insert
suspend fun insert(profile: Profile)
}

View File

@@ -0,0 +1,22 @@
package ac.aux.compose.database.dao
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 androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ReactionDao {
@Query("SELECT * FROM Reaction")
fun getAllReactions(): List<Reaction>
@Query("SELECT * FROM Reaction WHERE id = :id")
fun getReactionById(id: String): Flow<Reaction?>
@Insert
suspend fun insert(reaction: Reaction)
}

View File

@@ -0,0 +1,21 @@
package ac.aux.compose.database.dao
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
import ac.aux.compose.database.model.Repost
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface RepostDao {
@Query("SELECT * FROM Repost")
fun getAllReposts(): List<Repost>
@Query("SELECT * FROM Repost WHERE id = :id")
fun getRepostById(id: String): Flow<Repost?>
@Insert
suspend fun insert(repost: Repost)
}

View File

@@ -0,0 +1,18 @@
package ac.aux.compose.database.dao
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
import ac.aux.compose.database.model.Zap
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ZapDao {
@Query("SELECT * FROM Zap WHERE id = :id")
fun getZapById(id: String): Flow<Zap?>
@Insert
suspend fun insert(zap: Zap)
}

View File

@@ -0,0 +1,53 @@
package ac.aux.compose.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
import androidx.room.Index
import androidx.room.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
data class NostrEvent(
@PrimaryKey
val id: String,
val pubKey: HexKey,
val createdAt: Instant,
val savedAt: Instant,
val broadcastedAt: Instant,
val kind: Kind,
val tags: TagArray,
val content: String,
val sig: HexKey,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as NostrEvent
if (kind != other.kind) return false
if (id != other.id) return false
if (pubKey != other.pubKey) return false
if (createdAt != other.createdAt) return false
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (sig != other.sig) return false
return true
}
override fun hashCode(): Int {
var result = kind
result = 31 * result + id.hashCode()
result = 31 * result + pubKey.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + sig.hashCode()
return result
}
}

View File

@@ -0,0 +1,76 @@
package ac.aux.compose.database.model
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.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["publicKey"],
childColumns = ["profilePublicKey"],
onDelete = ForeignKey.CASCADE,
),
// This might cause problems... but we gonna make the feed so cool if it works...
ForeignKey(
entity = NostrEvent::class,
parentColumns = ["id"],
childColumns = ["nostrEventId"],
onDelete = ForeignKey.CASCADE,
),
// Support post replies
ForeignKey(
entity = Post::class,
parentColumns = ["id"],
childColumns = ["replyToId"],
onDelete = ForeignKey.CASCADE,
),
// Support reposts
ForeignKey(
entity = Post::class,
parentColumns = ["id"],
childColumns = ["repostId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("profilePublicKey"),
Index("nostrEventId"),
Index("replyToId"),
Index("repostId")
],
)
data class Post(
@PrimaryKey
val id: HexKey,
val nostrEventId: HexKey, // Might be a replaceable nostr event?
val replyToId: HexKey? = null,
val repostId: HexKey? = null,
val quote: HexKey? = null,
val profilePublicKey: HexKey,
val createdAt: Instant,
val savedAt: Instant,
val broadcastedAt: Instant,
val content: String,
) {
companion object {
val DUMMY_POSTS = arrayOf(
Post(
id = "genesisnoteid",
nostrEventId = "genesisnoteid", // Need to check how to handle addressableEvents...
profilePublicKey = "satoshi",
createdAt = Clock.System.now(),
savedAt = Clock.System.now(),
broadcastedAt = Clock.System.now(),
content = "Chancellor the rapper...",
)
)
}
}

View File

@@ -0,0 +1,42 @@
package ac.aux.compose.database.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
foreignKeys = [
ForeignKey(
entity = NostrEvent::class,
parentColumns = ["id"],
childColumns = ["nostrEventId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("nostrEventId"),
],
)
data class Profile(
@PrimaryKey
val publicKey: String,
val nostrEventId: String,
val userName: String? = null, // name in the nostr event...
val displayName: String? = null,
val picture: String? = null,
val banner: String? = null,
val website: String? = null,
val about: String? = null,
val bot: Boolean? = null,
val pronouns: String? = null,
val nip05: String? = null,
val domain: String? = null,
val lud06: String? = null,
val lud16: String? = null,
val twitter: String? = null
) {
companion object {
val DUMMAY_PROFILES = arrayOf<Profile>()
}
}

View File

@@ -0,0 +1,50 @@
package ac.aux.compose.database.model
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.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["publicKey"],
childColumns = ["profilePublicKey"],
onDelete = ForeignKey.CASCADE,
),
// This might cause problems... but we gonna make the feed so cool if it works...
ForeignKey(
entity = NostrEvent::class,
parentColumns = ["id"],
childColumns = ["nostrEventId"],
onDelete = ForeignKey.CASCADE,
),
// Support post replies
ForeignKey(
entity = Post::class,
parentColumns = ["id"],
childColumns = ["replyToId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("profilePublicKey"),
Index("nostrEventId"),
Index("replyToId")
],
)
data class Reaction(
@PrimaryKey
val id: HexKey,
val nostrEventId: HexKey, // Might be a replaceable nostr event?
val replyToId: HexKey,
val profilePublicKey: HexKey,
val createdAt: Instant,
val content: String,
)

View File

@@ -0,0 +1,50 @@
package ac.aux.compose.database.model
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.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["publicKey"],
childColumns = ["profilePublicKey"],
onDelete = ForeignKey.CASCADE,
),
// This might cause problems... but we gonna make the feed so cool if it works...
ForeignKey(
entity = NostrEvent::class,
parentColumns = ["id"],
childColumns = ["nostrEventId"],
onDelete = ForeignKey.CASCADE,
),
// Support post replies
ForeignKey(
entity = Post::class,
parentColumns = ["id"],
childColumns = ["postId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("profilePublicKey"),
Index("nostrEventId"),
Index("postId")
],
)
data class Repost(
@PrimaryKey
val id: HexKey,
val nostrEventId: HexKey, // Might be a replaceable nostr event?
val postId: HexKey? = null,
val profilePublicKey: HexKey,
val createdAt: Instant,
val content: String,
)

View File

@@ -0,0 +1,55 @@
package ac.aux.compose.database.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Instant
@Entity(
foreignKeys = [
ForeignKey(
entity = Profile::class,
parentColumns = ["publicKey"],
childColumns = ["senderPublicKey"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = Profile::class,
parentColumns = ["publicKey"],
childColumns = ["receiverPublicKey"],
onDelete = ForeignKey.CASCADE,
),
// This might cause problems... but we gonna make the feed so cool if it works...
ForeignKey(
entity = NostrEvent::class,
parentColumns = ["id"],
childColumns = ["nostrEventId"],
onDelete = ForeignKey.CASCADE,
),
// Support post replies
ForeignKey(
entity = Post::class,
parentColumns = ["id"],
childColumns = ["postId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("senderPublicKey"),
Index("nostrEventId"),
Index("postId")
],
)
data class Zap(
@PrimaryKey
val id: HexKey,
val nostrEventId: HexKey, // Might be a replaceable nostr event?
val postId: HexKey,
val senderPublicKey: HexKey,
val receiverPublicKey: HexKey,
val createdAt: Instant,
val message: String?,
val invoice: String?,
)

View File

@@ -0,0 +1,24 @@
package ac.aux.compose.database.repository
import ac.aux.compose.database.AuxDatabase
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
import ac.aux.compose.repository.PostRepository
class DatabasePostRepository(
private val database: AuxDatabase
): PostRepository {
override suspend fun publishPost(
post: Post,
nostrEvent: NostrEvent
) {
val nostrDao = database.nostrDao()
nostrDao.insertPostAndNostrEvent(
post,
nostrEvent
)
}
}

View File

@@ -0,0 +1,8 @@
package ac.aux.compose.repository
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
interface PostRepository {
suspend fun publishPost(post: Post, nostrEvent: NostrEvent)
}

View File

@@ -1,11 +1,12 @@
package ac.aux.compose.ui.composable
import ac.aux.compose.ui.composable.widgets.LoadingDataIndicator
import ac.aux.compose.ui.composable.widgets.feed.FeedListItem
import ac.aux.compose.ui.composable.widgets.feed.ListView
import ac.aux.compose.ui.composable.widgets.feed.LoadedFeedView
import ac.aux.compose.ui.theme.AuxTheme
import ac.aux.compose.ui.view.model.FeedListViewModel
import ac.aux.compose.ui.view.state.FeedListUIState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -25,6 +26,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.tooling.preview.Preview
@@ -91,10 +93,23 @@ fun FeedScreen(
Text("Something went wrong")
}
is FeedListUIState.Loaded -> {
LoadedFeedView(
events = feedListUIState.events,
onNavigateToEvent = onNavigateToEvent
)
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = feedListUIState.events,
key = { e -> e.id }
) { event ->
FeedListItem(
event = event,
onNavigateToEvent = {
onNavigateToEvent.invoke(event)
}
)
}
}
}
}
}

View File

@@ -3,6 +3,7 @@ package ac.aux.compose.ui.composable.widgets.feed
import ac.aux.compose.ui.theme.AuxTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
@@ -28,6 +29,9 @@ fun Event.ListView() {
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row {
}
Text(
text = content,
)

View File

@@ -1,35 +0,0 @@
package ac.aux.compose.ui.composable.widgets.feed
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.core.Event
@Composable
fun LoadedFeedView(
events: List<Event>,
onNavigateToEvent: (Event) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = events,
key = { e -> e.id }
) { event ->
FeedListItem(
event = event,
onNavigateToEvent = {
onNavigateToEvent.invoke(event)
}
)
}
}
}

View File

@@ -15,11 +15,14 @@ junit = "4.13.2"
kermit = "2.0.8"
kotlin = "2.3.0"
kotlinx-coroutines = "1.10.2"
ksp = "2.3.6"
material3 = "1.10.0-alpha05"
materialIconsCore = "1.7.3"
materialIconsExtended = "1.7.3"
navigationCompose = "2.9.2"
okhttp = "5.3.2"
room = "2.8.4"
sqlite = "2.6.2"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -30,6 +33,10 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
@@ -49,9 +56,11 @@ okhttp-coroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines"
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidx-room = { id = "androidx.room", version.ref = "room" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinPluginSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlinPluginSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }