Add chatroom functionality

This commit is contained in:
Kgothatso Ngako
2026-05-18 22:52:09 +02:00
parent 51220dbf5e
commit 7041fefbbd
19 changed files with 922 additions and 2799 deletions

View File

@@ -8,7 +8,7 @@ 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.GiftWrapPayloadDao
import ac.cord.auxiliary.compose.database.dao.InReplyToRelationDao
import ac.cord.auxiliary.compose.database.dao.MentionDao
import ac.cord.auxiliary.compose.database.dao.NegentropySynchronizeRequestDao
@@ -34,7 +34,7 @@ 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.GiftWrapPayload
import ac.cord.auxiliary.compose.database.model.InReplyToRelation
import ac.cord.auxiliary.compose.database.model.Mention
import ac.cord.auxiliary.compose.database.model.NegentropySynchronizeRequest
@@ -72,7 +72,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
InReplyToRelation::class,
GiftWrapMessage::class,
GiftWrapSeal::class,
GiftWrapUnsigned::class,
GiftWrapPayload::class,
Mention::class,
NegentropySynchronizeRequest::class,
NegentropySynchronizeResult::class,
@@ -90,13 +90,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 2,
autoMigrations = [
AutoMigration(
from = 1,
to = 2
)
]
version = 1
)
@TypeConverters(AuxConverters::class)
abstract class AuxDatabase: RoomDatabase() {
@@ -113,7 +107,7 @@ abstract class AuxDatabase: RoomDatabase() {
abstract fun giftWrapSealDao(): GiftWrapSealDao
abstract fun giftWrapUnsignedDao(): GiftWrapUnsignedDao
abstract fun giftWrapUnsignedDao(): GiftWrapPayloadDao
abstract fun inReplyToRelationDao(): InReplyToRelationDao

View File

@@ -2,6 +2,7 @@ package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.ChatRoom
import ac.cord.auxiliary.compose.database.model.Relay
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalChatRoom
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
@@ -10,9 +11,11 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface ChatRoomDao {
@Query("SELECT * FROM ChatRoom WHERE id = :id")
fun findChatRoomById(id: String): ChatRoom?
@Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey")
fun observeChatRoomByUserPublicKey(userPublicKey: String): Flow<List<ChatRoom>>
fun observeChatRoomByUserPublicKey(userPublicKey: String): Flow<List<LocalChatRoom>>
@Upsert
suspend fun upsert(chatRoom: ChatRoom)

View File

@@ -0,0 +1,16 @@
package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.GiftWrapPayload
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
@Dao
interface GiftWrapPayloadDao {
@Query("SELECT * FROM GiftWrapPayload")
fun getAllGiftWrapUnsigned(): List<GiftWrapPayload>
@Upsert
suspend fun upsert(giftWrapPayload: GiftWrapPayload)
}

View File

@@ -1,19 +0,0 @@
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

@@ -3,6 +3,8 @@ package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.AuxDatabase
import ac.cord.auxiliary.compose.database.GENESIS_AT
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.Mention
import ac.cord.auxiliary.compose.database.model.NegentropySynchronizeRequest
@@ -10,24 +12,21 @@ import ac.cord.auxiliary.compose.database.model.NostrEvent
import ac.cord.auxiliary.compose.database.model.NostrEventRelay
import ac.cord.auxiliary.compose.database.model.Post
import ac.cord.auxiliary.compose.database.model.Profile
import ac.cord.auxiliary.compose.database.model.SynchronizeNostrEventRequest
import ac.cord.auxiliary.compose.database.model.UnsignedNostrEvent
import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter
import ac.cord.auxiliary.compose.network.serialization.encodeToJsonString
import ac.cord.auxiliary.compose.nostr.Relays
import ac.cord.auxiliary.compose.managers.SeedManager
import ac.cord.auxiliary.compose.managers.toHex
import androidx.room3.Dao
import androidx.room3.Transaction
import androidx.room3.useWriterConnection
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.firstTaggedQuote
import kotlin.math.ceil
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import fr.acinq.bitcoin.crypto.musig2.Musig2
import kotlin.time.Clock
import kotlin.time.Instant
@Dao
abstract class NostrDao(
@@ -124,7 +123,7 @@ abstract class NostrDao(
} else {
// Nothing else needs to be done
val profile = database.profileDao().getProfileByPublicKey(nostrEvent.pubKey)
if (profile?.createdAt == GENESIS_AT) {
if (profile?.createdAt == GENESIS_AT && nostrEvent.kind == MetadataEvent.KIND) {
logger.i("This is a placeholder profile might need to get synced...: ${profile.publicKey}")
// Setup the sync here...
} else {
@@ -158,13 +157,13 @@ abstract class NostrDao(
val profilePublicKeysToSync = mutableMapOf<String, MutableSet<String>>()
val eventIdsToSync = mutableMapOf<String, MutableSet<String>>()
if (nostrEvent.unsignedNostrEventId == null) {
if (nostrEvent.unsignedNostrEventId == null && nostrEvent.kind != GiftWrapEvent.KIND) {
// Find or create profile with the pubKey... if not found submit a sync request...
val profile = database.profileDao().getProfileByPublicKey(nostrEvent.pubKey)
if (profile == null) {
val placeHolderProfile = Profile(
displayName = "LOADING PROFILE...",
displayName = "LOADING...",
publicKey = nostrEvent.pubKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
@@ -185,6 +184,67 @@ abstract class NostrDao(
logger.w("We somehow have an unsignedNostrEvent: $nostrEvent")
}
// Index reposts first because they have the contained posts...
nostrEvent.toRepostedRelationWithContainedPost()?.let { (repostedRelation, containedPost) ->
// Find or create placeHolder note that is being reposted
val repostedNostrEvent = database.nostrEventDao().getNostrEventById(repostedRelation.repostedNostrEventId)
val repostedProfilePublicKey = if (repostedNostrEvent == null) {
// Sync event...
if (eventIdsToSync[relayURL] == null) {
eventIdsToSync[relayURL] = mutableSetOf()
}
eventIdsToSync[relayURL]?.add(repostedRelation.repostedNostrEventId)
database.nostrEventDao().insert(
if (containedPost != null) {
NostrEvent(
id = containedPost.id,
content = containedPost.content,
createdAt = Instant.fromEpochSeconds(containedPost.createdAt),
pubKey = containedPost.pubKey,
sig = containedPost.sig,
kind = containedPost.kind,
tags = containedPost.tags
)
} else {
NostrEvent(
id = repostedRelation.repostedNostrEventId,
content = "Pending Sync",
createdAt = GENESIS_AT,
pubKey = nostrEvent.pubKey,
sig = "",
kind = nostrEvent.kind,
tags = emptyArray()
)
}
)
repostedRelation.repostedProfilePublicKey
} else {
repostedRelation.repostedProfilePublicKey ?: repostedNostrEvent.pubKey
}
val repostedProfile = repostedProfilePublicKey?.let { database.profileDao().getProfileByPublicKey(it) }
if (repostedProfilePublicKey != null && repostedProfile == null) {
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING...",
publicKey = repostedProfilePublicKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
)
)
}
database.repostedRelationDao().upsert(
repostedRelation.copy(
repostedProfilePublicKey = repostedProfilePublicKey
)
)
}
// Sync tagged events and authors...
nostrEvent.tags.taggedEvents().take(4).forEach { taggedEvent -> // We only look at the first 3 tagged events...
val taggedNostrEvent = database.nostrEventDao().getNostrEventById(taggedEvent.eventId)
@@ -227,7 +287,7 @@ abstract class NostrDao(
// Save a placeholder
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING PROFILE...",
displayName = "LOADING...",
publicKey = taggedUser.pubKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
@@ -243,11 +303,10 @@ abstract class NostrDao(
)
)
if (recommendedRelay != null) {
if (profilePublicKeysToSync[relayURL] == null) {
profilePublicKeysToSync[relayURL] = mutableSetOf()
if (profilePublicKeysToSync[recommendedRelay] == null) {
profilePublicKeysToSync[recommendedRelay] = mutableSetOf()
}
profilePublicKeysToSync[relayURL]?.add(taggedUser.pubKey)
profilePublicKeysToSync[recommendedRelay]?.add(taggedUser.pubKey)
} else {
if (profilePublicKeysToSync[relayURL] == null) {
profilePublicKeysToSync[relayURL] = mutableSetOf()
@@ -262,6 +321,100 @@ abstract class NostrDao(
database.profileDao().upsert(profile)
}
nostrEvent.toGiftWrapMessageWithReceiverPTag()?.let { giftWrapMessage ->
val giftWrapReceiverProfile = database.profileDao().getProfileByPublicKey(giftWrapMessage.receiverPublicKey)
if (giftWrapReceiverProfile == null) {
// Save a placeholder
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING...",
publicKey = giftWrapMessage.receiverPublicKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
)
)
val recommendRelayUrl = giftWrapMessage.receiverRelayHit
if (recommendRelayUrl != null) {
if (profilePublicKeysToSync[recommendRelayUrl] == null) {
profilePublicKeysToSync[recommendRelayUrl] = mutableSetOf()
}
profilePublicKeysToSync[recommendRelayUrl]?.add(giftWrapMessage.receiverPublicKey)
} else {
if (profilePublicKeysToSync[relayURL] == null) {
profilePublicKeysToSync[relayURL] = mutableSetOf()
}
profilePublicKeysToSync[relayURL]?.add(
giftWrapMessage.receiverPublicKey
)
}
}
database.giftWrapMessageDao().upsert(
giftWrapMessage
)
// TODO: Might want to process this somewhere else where privateKey is available...
SeedManager.activeKeyPair().privKey?.let { activeUserPrivateKey ->
giftWrapMessage.decryptGiftWrapSeal(
activeUserPrivateKey
)?.let { giftWrapSeal ->
database.giftWrapSealDao().upsert(
giftWrapSeal
)
giftWrapSeal.decryptGiftWrapPayload(
activeUserPrivateKey
)?.let { giftWrapPayload ->
database.giftWrapUnsignedDao().upsert(
giftWrapPayload
)
val chatRoomId = Musig2.aggregateKeys(
giftWrapPayload.receiverPublicKeys()
).value.toHex()
// Process chatMessage and chatRoom stuff...
// Find or create chatRoom
val chatRoom = database.chatRoomDao().findChatRoomById(
chatRoomId
)
if (chatRoom == null) {
database.chatRoomDao().upsert(
ChatRoom(
id = chatRoomId,
userPublicKey = SeedManager.activeKeyPair().pubKey.toHex(),
subject = null, // TODO: Get subject from tags...
createdAt = giftWrapPayload.createdAt,
initialGiftWrapUnsignedId = giftWrapPayload.id
)
)
} else {
// TODO: Update subject from tags...
}
// Save chatMessage
database.chatMessageDao().upsert(
ChatMessage(
id = giftWrapPayload.id,
senderPublicKey = giftWrapPayload.publicKey,
isUserMessage = SeedManager.activePublicKey().toHex() == giftWrapPayload.publicKey,
chatRoomId = chatRoomId,
createdAt = giftWrapPayload.createdAt,
content = giftWrapPayload.content,
)
)
}
}
}
}
nostrEvent.toPost()?.let { post ->
if (post.replyToId != null) {
// Find or create placeHolder note that is replyTo
@@ -334,7 +487,7 @@ abstract class NostrDao(
if (quotedProfilePublicKey != null && quotedProfile == null) {
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING PROFILE...",
displayName = "LOADING...",
publicKey = quotedProfilePublicKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
@@ -349,52 +502,6 @@ abstract class NostrDao(
)
)
}
nostrEvent.toRepostedRelation()?.let { repostedRelation ->
// Find or create placeHolder note that is being reposted
val repostedNostrEvent = database.nostrEventDao().getNostrEventById(repostedRelation.repostedNostrEventId)
val repostedProfilePublicKey = if (repostedNostrEvent == null) {
// Sync event...
if (eventIdsToSync[relayURL] == null) {
eventIdsToSync[relayURL] = mutableSetOf()
}
eventIdsToSync[relayURL]?.add(repostedRelation.repostedNostrEventId)
database.nostrEventDao().insert(
NostrEvent(
id = repostedRelation.repostedNostrEventId,
content = "Pending Sync",
createdAt = GENESIS_AT,
pubKey = nostrEvent.pubKey,
sig = "",
kind = nostrEvent.kind,
tags = emptyArray()
)
)
repostedRelation.repostedProfilePublicKey
} else {
repostedRelation.repostedProfilePublicKey ?: repostedNostrEvent.pubKey
}
val repostedProfile = repostedProfilePublicKey?.let { database.profileDao().getProfileByPublicKey(it) }
if (repostedProfilePublicKey != null && repostedProfile == null) {
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING PROFILE...",
publicKey = repostedProfilePublicKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
)
)
}
database.repostedRelationDao().upsert(
repostedRelation.copy(
repostedProfilePublicKey = repostedProfilePublicKey
)
)
}
nostrEvent.toInReplyToRelation()?.let { inReplyToRelation ->
@@ -442,7 +549,7 @@ abstract class NostrDao(
if (inReplyToRootProfilePublicKey != null && inReplyToProfile == null) {
database.profileDao().insertPlaceholderProfile(
Profile(
displayName = "LOADING PROFILE...",
displayName = "LOADING...",
publicKey = inReplyToRootProfilePublicKey,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
@@ -502,7 +609,7 @@ abstract class NostrDao(
if (profile == null) {
val placeHolderProfile = Profile(
displayName = "LOADING PROFILE...",
displayName = "LOADING...",
publicKey = followedHexKEys,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,

View File

@@ -8,6 +8,7 @@ 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
@Entity(
@@ -19,9 +20,9 @@ import kotlin.time.Instant
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = GiftWrapUnsigned::class,
entity = GiftWrapPayload::class,
parentColumns = ["id"],
childColumns = ["giftWrapUnsignedId"],
childColumns = ["id"],
onDelete = ForeignKey.CASCADE,
),
],
@@ -35,17 +36,15 @@ data class ChatMessage(
val chatRoomId: String,
val giftWrapUnsignedId: String,
val replyToEventId: String?,
val quotedEventId: String?,
val replyToEventId: String? = null,
val quotedEventId: String? = null,
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)
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = Clock.System.now(),
override val viewedAt: Instant? = null,
override val deletedAt: Instant? = null, // Who this message should be seen by (used to derive conversationId)
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
}

View File

@@ -20,7 +20,7 @@ import kotlin.time.Instant
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = GiftWrapUnsigned::class,
entity = GiftWrapPayload::class,
parentColumns = ["id"],
childColumns = ["initialGiftWrapUnsignedId"],
onDelete = ForeignKey.CASCADE,
@@ -50,11 +50,6 @@ data class ChatRoom(
*/
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
* [

View File

@@ -11,6 +11,15 @@ 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 com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import fr.acinq.bitcoin.PrivateKey
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
@@ -33,12 +42,19 @@ data class GiftWrapMessage(
val publicKey: String,
val kind: Kind = 1059,
/**
* Receiver Public Key Derived from tags
*
* [
* ["p", receiverPublicKey, "<relay-url>"] // receiver
* ]
*
*/
val tags: Array<Array<String>>,
val receiverPublicKey: String,
val receiverRelayHit: String?,
/**
*
* encrypt(GiftWrapSeal, randomPrivateKey, receiverPublicKey)
@@ -57,47 +73,50 @@ data class GiftWrapMessage(
/**
* 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)
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = Clock.System.now(),
override val viewedAt: Instant? = null,
override val deletedAt: Instant? = null, // 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
suspend fun decryptGiftWrapSeal(
privateKey: ByteArray
): GiftWrapSeal? {
if (kind == GiftWrapEvent.KIND) {
val giftWrapEvent = GiftWrapEvent(
id = id,
pubKey = publicKey,
tags = arrayOf(
PTag(
pubKey = receiverPublicKey,
relayHint = receiverRelayHit?.let { NormalizedRelayUrl(it) }
).toTagArray()
),
createdAt = createdAt.epochSeconds,
content = content,
sig = signature
)
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
val nostrSigner = NostrSignerInternal(
keyPair = KeyPair(privKey = privateKey)
)
return true
}
giftWrapEvent.unwrapOrNull(
nostrSigner
)?.let { giftWrapSeal ->
return GiftWrapSeal(
id = giftWrapSeal.id,
publicKey = giftWrapSeal.pubKey,
content = giftWrapSeal.content,
tags = giftWrapSeal.tags,
createdAt = Instant.fromEpochSeconds(giftWrapSeal.createdAt),
signature = giftWrapSeal.sig
)
}
}
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
return null
}
}

View File

@@ -9,6 +9,9 @@ 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.tags.people.taggedUsers
import fr.acinq.bitcoin.PublicKey
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
@@ -21,7 +24,7 @@ import kotlin.time.Instant
),
],
)
data class GiftWrapUnsigned(
data class GiftWrapPayload( // TODO: Rename this to GiftWrapPayload...
@PrimaryKey
val id: HexKey,
@@ -31,9 +34,9 @@ data class GiftWrapUnsigned(
val publicKey: String,
/**
* Chat Message Kind
* Payload Event Kind
*/
val kind: Kind = 14,
val kind: Kind,
/**
* Tags for the unsigned giftwrap
@@ -54,7 +57,7 @@ data class GiftWrapUnsigned(
* content MUST be plain text. Fields id and created_at are required.
*/
val content: String,
val quotedEventId: String?,
val quotedEventId: String? = null,
/**
* Associated GiftWrapSeal
@@ -64,17 +67,28 @@ data class GiftWrapUnsigned(
/**
* 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)
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = Clock.System.now(),
override val viewedAt: Instant? = null,
override val deletedAt: Instant? = null, // Who this message should be seen by (used to derive conversationId)
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
fun receiverPublicKeys(): List<PublicKey> = try {
tags.taggedUsers().map {
PublicKey(
it.pubKey.hexToByteArray()
)
}
} catch (e: Throwable) {
return emptyList()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as GiftWrapUnsigned
other as GiftWrapPayload
if (kind != other.kind) return false
if (id != other.id) return false

View File

@@ -9,7 +9,10 @@ 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 com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import kotlin.time.Clock
import kotlin.time.Instant
@Entity(
@@ -56,12 +59,48 @@ data class GiftWrapSeal(
/**
* 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)
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = Clock.System.now(),
override val viewedAt: Instant? = null,
override val deletedAt: Instant? = null, // Who this message should be seen by (used to derive conversationId)
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
suspend fun decryptGiftWrapPayload(
privateKey: ByteArray
): GiftWrapPayload? {
if (kind == SealedRumorEvent.KIND) {
val sealedRumorEvent = SealedRumorEvent(
id = id,
pubKey = publicKey,
tags = tags,
createdAt = createdAt.epochSeconds,
content = content,
sig = signature
)
val nostrSigner = NostrSignerInternal(
keyPair = KeyPair(privKey = privateKey)
)
sealedRumorEvent.unsealOrNull(
nostrSigner
)?.let { giftWrapPayload ->
return GiftWrapPayload(
id = giftWrapPayload.id,
publicKey = giftWrapPayload.pubKey,
kind = giftWrapPayload.kind,
tags = giftWrapPayload.tags,
content = giftWrapPayload.content,
createdAt = Instant.fromEpochSeconds(giftWrapPayload.createdAt),
)
}
}
return null
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false

View File

@@ -5,7 +5,6 @@ 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.UnsignedNostrEventEntity
import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter
import ac.cord.auxiliary.compose.ui.composable.widgets.content.ContentParser
import ac.cord.auxiliary.compose.ui.composable.widgets.content.ContentSegment
import androidx.room3.Entity
@@ -16,10 +15,10 @@ import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag
@@ -27,6 +26,7 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.firstTaggedQuote
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -180,6 +180,40 @@ data class NostrEvent(
return null
}
fun toGiftWrapMessageWithReceiverPTag(): GiftWrapMessage? = try {
if (kind == GiftWrapEvent.KIND) {
val giftWrapEvent = GiftWrapEvent(
id = id,
pubKey = pubKey,
createdAt = createdAt.epochSeconds,
kind = kind,
tags = tags,
content = content,
sig = sig
)
return giftWrapEvent.taggedUsers().firstOrNull()?.let { receiverPTag ->
GiftWrapMessage(
id = giftWrapEvent.id,
kind = giftWrapEvent.kind,
publicKey = giftWrapEvent.pubKey,
content = giftWrapEvent.content,
createdAt = Instant.fromEpochSeconds(giftWrapEvent.createdAt),
receiverPublicKey = receiverPTag.pubKey,
receiverRelayHit = receiverPTag.relayHint?.url,
signature = giftWrapEvent.sig,
nostrEventId = id,
)
}
}
return null
} catch (e: Throwable) {
return null
}
fun toReaction(): Reaction? = try {
if (kind == ReactionEvent.KIND) {
val reactionEvent = EventFactory.create<ReactionEvent>(
@@ -212,7 +246,7 @@ data class NostrEvent(
return null
}
fun toRepostedRelation(): RepostedRelation? = try {
fun toRepostedRelationWithContainedPost(): Pair<RepostedRelation, Event?>? = try {
if (kind == RepostEvent.KIND) {
val repostEvent = EventFactory.create<RepostEvent>(
id = id,
@@ -224,16 +258,37 @@ data class NostrEvent(
sig = sig
)
return repostEvent.containedPost()?.let { containedPost ->
RepostedRelation(
repostedNostrEventId = containedPost.id,
repostedProfilePublicKey = containedPost.pubKey,
return repostEvent.containedPost().let { containedPost ->
if (containedPost != null) {
Pair(
RepostedRelation(
repostedNostrEventId = containedPost.id,
repostedProfilePublicKey = containedPost.pubKey,
repostingNostrEventId = repostEvent.id,
repostingNostrEventId = repostEvent.id,
createdAt = Instant.fromEpochSeconds(repostEvent.createdAt),
updatedAt = Instant.fromEpochSeconds(repostEvent.createdAt),
)
createdAt = Instant.fromEpochSeconds(repostEvent.createdAt),
updatedAt = Instant.fromEpochSeconds(repostEvent.createdAt),
),
containedPost
)
} else {
repostEvent.boostedEvent()?.let { eTag ->
Pair(
RepostedRelation(
repostedNostrEventId = eTag.eventId,
repostedProfilePublicKey = eTag.author,
repostingNostrEventId = repostEvent.id,
createdAt = Instant.fromEpochSeconds(repostEvent.createdAt),
updatedAt = Instant.fromEpochSeconds(repostEvent.createdAt),
),
null
)
}
}
}
}

View File

@@ -0,0 +1,11 @@
package ac.cord.auxiliary.compose.database.model.intermdiate
import ac.cord.auxiliary.compose.database.model.ChatRoom
import ac.cord.auxiliary.compose.database.model.Post
import ac.cord.auxiliary.compose.database.model.Profile
import androidx.room3.Embedded
import androidx.room3.Relation
data class LocalChatRoom(
@Embedded val chatRoom: ChatRoom,
)

View File

@@ -15,6 +15,7 @@ import ac.cord.auxiliary.compose.database.model.UnsignedNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalAccount
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalChatRoom
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowers
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
@@ -682,6 +683,10 @@ class DatabaseNostrRepository(
return database.profileDao().observeProfileWithFollowingByPublicKey(publicKey)
}
override suspend fun observeChatRoomsByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> {
return database.chatRoomDao().observeChatRoomByUserPublicKey(publicKey)
}
override suspend fun getRecentSearches(): List<RecentSearch> {
return database.recentSearchDao().getAllRecentSearches()
}

View File

@@ -12,6 +12,7 @@ import ac.cord.auxiliary.compose.database.model.UnsignedNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalAccount
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalChatRoom
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowers
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
@@ -122,6 +123,8 @@ interface NostrRepository {
suspend fun observeProfileWithFollowing(publicKey: String): Flow<LocalProfileWithFollowing?>
suspend fun observeChatRoomsByPublicKey(publicKey: String): Flow<List<LocalChatRoom>>
suspend fun getRecentSearches(): List<RecentSearch>
suspend fun removeRecentSearch(recentSearch: RecentSearch)
@@ -289,6 +292,10 @@ interface NostrRepository {
TODO("Not yet implemented")
}
override suspend fun observeChatRoomsByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> {
TODO("Not yet implemented")
}
override suspend fun getRecentSearches(): List<RecentSearch> {
TODO("Not yet implemented")
}

View File

@@ -8,7 +8,6 @@ import ac.cord.auxiliary.compose.repository.NostrRepository
import ac.cord.auxiliary.compose.ui.view.state.ChatRoomListUIState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -49,51 +48,20 @@ class ChatRoomListViewModel(
var chatRoomListUIState: ChatRoomListUIState by mutableStateOf(initialChatRoomListUIState)
private set
val isSynchronizationPending: MutableState<Boolean> = mutableStateOf(false)
fun initiate() {
logger.d("init")
// scheduleSynchronization()
observeLocalNostrFeed()
// observeSynchronizeNostrEventRequestByPurposeAndStatusCount()
scheduleSynchronization()
observeChatRoomFeed()
}
fun observeLocalNostrFeed() {
logger.d("observeLocalNostrFeed")
fun observeChatRoomFeed() {
logger.d("observeChatRoomFeed")
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.observeProfileWithFollowing(
nostrRepository.observeChatRoomsByPublicKey(
publicKey
).distinctUntilChanged().collect {
chatRoomListUIState = ChatRoomListUIState.Loaded(
chatRoomList = listOf(
ChatRoom(
id ="steveBikoId",
userPublicKey = "steveBikoId",
name = "Steve Biko",
subject = "BC: Systems",
initialGiftWrapUnsignedId = "",
),
ChatRoom(
id = "chrisHaniId",
userPublicKey = "chrisHaniId",
name = "Chris Hani",
subject = "MK: Gym",
initialGiftWrapUnsignedId = "",
),
ChatRoom(
id = "desmondTutuId",
userPublicKey = "desmondTutuId",
name = "Desmond Tutu",
subject = "TRC: Masses",
initialGiftWrapUnsignedId = "",
),
ChatRoom(
id = "hipHopPantsulaId",
userPublicKey = "hipHopPantsulaId",
name = "Hip Hop Pantsula",
subject = "Motswako: Jabba",
initialGiftWrapUnsignedId = "",
)
)
chatRoomList = it
)
}
}
@@ -110,7 +78,7 @@ class ChatRoomListViewModel(
relayURL = normalizedRelayUrl.url,
synchronizationFilter = synchronizationFilter
),
purpose = "following",
purpose = "chat",
synchronizationFilter = synchronizationFilter,
relayURL = normalizedRelayUrl.url,
level = 0
@@ -120,16 +88,6 @@ class ChatRoomListViewModel(
}
}
fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount() {
logger.d("observeSynchronizeNostrEventRequestByPurposeAndStatusCount")
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.observeSynchronizeNostrEventRequestByPurposeAndStatusCount("feed").distinctUntilChanged().collect { count ->
logger.d("Pending/Processing Synchronization Request: $count")
isSynchronizationPending.value = count > 0
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RenderFeed(
@@ -177,8 +135,8 @@ class ChatRoomListViewModel(
items(
items = chatRoomListUIState.chatRoomList,
key = { it.id }
) { chatRoom ->
key = { it.chatRoom.id }
) { localChatRoom ->
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
@@ -190,16 +148,13 @@ class ChatRoomListViewModel(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = chatRoom.name ?: chatRoom.subject ?: "Unnamed chat"
text = localChatRoom.chatRoom.subject ?: "Unnamed chat"
)
}
}
}
}
}
}
}
ChatRoomListUIState.Loading -> {
@@ -226,7 +181,7 @@ class ChatRoomListViewModel(
}
companion object {
const val TAG = "MessageListViewModel"
const val TAG = "ChatRoomListViewModel"
fun factory(
initialChatRoomListUIState: ChatRoomListUIState = ChatRoomListUIState.Loading,

View File

@@ -23,6 +23,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -116,11 +117,11 @@ class HomeViewModel(
}
HomeScreenType.Messages -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
ContactListEvent.KIND,
GiftWrapEvent.KIND,
),
tags = mapOf(
Pair("p", listOf(publicKey))
),
limit = 50
)

View File

@@ -1,10 +1,10 @@
package ac.cord.auxiliary.compose.ui.view.state
import ac.cord.auxiliary.compose.database.model.ChatRoom
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalChatRoom
sealed interface ChatRoomListUIState {
data class Loaded(
val chatRoomList: List<ChatRoom>
val chatRoomList: List<LocalChatRoom>
): ChatRoomListUIState
data object Error: ChatRoomListUIState