Merge pull request #512 from barrydeen/feat/persist-decrypted-dms

Persist decrypted DMs to skip per-boot signer round-trips
This commit is contained in:
Barry Deen
2026-05-03 21:57:12 -04:00
committed by GitHub
6 changed files with 528 additions and 8 deletions
+112 -2
View File
@@ -200,10 +200,120 @@
}
],
"relations": []
},
{
"id": "4:7887606620514562424",
"lastPropertyId": "19:4886434588165198685",
"name": "DmMessageEntity",
"properties": [
{
"id": "1:5692570271165246660",
"name": "dbId",
"type": 6,
"flags": 1
},
{
"id": "2:7000677822556314201",
"name": "ownerPubkey",
"indexId": "9:6602733161228465634",
"type": 9,
"flags": 2048
},
{
"id": "3:6290484962841828502",
"name": "msgId",
"type": 9
},
{
"id": "4:2348777899365035302",
"name": "giftWrapId",
"type": 9
},
{
"id": "5:2069465575936337164",
"name": "ownerPlusGiftWrap",
"indexId": "10:3019350336205317210",
"type": 9,
"flags": 2080
},
{
"id": "6:2553884322257857954",
"name": "conversationKey",
"indexId": "11:2796688777436521560",
"type": 9,
"flags": 2048
},
{
"id": "7:8809979439595500948",
"name": "senderPubkey",
"type": 9
},
{
"id": "8:8843109929728616712",
"name": "content",
"type": 9
},
{
"id": "9:171907408823158128",
"name": "createdAt",
"type": 6
},
{
"id": "10:2547972877937268396",
"name": "rumorId",
"type": 9
},
{
"id": "11:1673398814149095965",
"name": "replyToId",
"type": 9
},
{
"id": "12:50500640324097366",
"name": "participantsJson",
"type": 9
},
{
"id": "13:8309166603960799477",
"name": "relayUrlsJson",
"type": 9
},
{
"id": "14:2180681062962341429",
"name": "reactionsJson",
"type": 9
},
{
"id": "15:4091937178395226174",
"name": "zapsJson",
"type": 9
},
{
"id": "16:3800181430749026871",
"name": "emojiMapJson",
"type": 9
},
{
"id": "17:3371948627029311978",
"name": "encryptedFileMetadataJson",
"type": 9
},
{
"id": "18:2402319826123827739",
"name": "debugGiftWrapJson",
"type": 9
},
{
"id": "19:4886434588165198685",
"name": "debugRumorJson",
"type": 9
}
],
"relations": []
}
],
"lastEntityId": "3:4980149860826606366",
"lastIndexId": "8:8850647956925906315",
"lastEntityId": "4:7887606620514562424",
"lastIndexId": "11:2796688777436521560",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
+11 -1
View File
@@ -115,7 +115,7 @@
},
{
"id": "3:4980149860826606366",
"lastPropertyId": "13:2695266999059672518",
"lastPropertyId": "15:5858957648151141575",
"name": "GroupMetaEntity",
"properties": [
{
@@ -187,6 +187,16 @@
"id": "13:2695266999059672518",
"name": "lastMessageAt",
"type": 6
},
{
"id": "14:2327490369406932737",
"name": "isRestricted",
"type": 1
},
{
"id": "15:5858957648151141575",
"name": "isHidden",
"type": 1
}
],
"relations": []
@@ -0,0 +1,47 @@
package com.wisp.app.db
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Index
import io.objectbox.annotation.Unique
/**
* Persisted decrypted NIP-17 DM. Keyed by gift-wrap event id so re-fetched gift wraps
* can be deduped before they're sent to the remote signer for re-decryption.
*
* Reactions, zaps, file metadata, emoji map and participants are stored as JSON since
* they're nested collections and don't need to be queryable.
*/
@Entity
data class DmMessageEntity(
@Id var dbId: Long = 0,
/** Account pubkey (hex) — the local user this conversation belongs to. */
@Index val ownerPubkey: String = "",
/** Internal DmMessage.id ("$giftWrapId:$rumorCreatedAt"). */
val msgId: String = "",
/** kind 1059 gift wrap event id. Unique per owner — see [ownerPlusGiftWrap]. */
val giftWrapId: String = "",
/** Composite "$ownerPubkey|$giftWrapId" for cross-account dedup. */
@Unique val ownerPlusGiftWrap: String = "",
/** DmRepository.conversationKey — sorted, comma-joined participant pubkeys. */
@Index val conversationKey: String = "",
val senderPubkey: String = "",
val content: String = "",
val createdAt: Long = 0L,
val rumorId: String = "",
val replyToId: String? = null,
/** Other participants (excluding owner) — JSON list. */
val participantsJson: String = "[]",
/** Set<String> of relay URLs that delivered this gift wrap — JSON list. */
val relayUrlsJson: String = "[]",
/** List<DmReaction> as JSON. */
val reactionsJson: String = "[]",
/** List<DmZap> as JSON. */
val zapsJson: String = "[]",
/** Map<String, String> emoji shortcode → URL — JSON. */
val emojiMapJson: String = "{}",
/** EncryptedFileMetadata as JSON, or null for regular text messages. */
val encryptedFileMetadataJson: String? = null,
val debugGiftWrapJson: String? = null,
val debugRumorJson: String? = null
)
@@ -0,0 +1,209 @@
package com.wisp.app.db
import android.util.Log
import com.wisp.app.nostr.DmMessage
import com.wisp.app.nostr.DmReaction
import com.wisp.app.nostr.DmZap
import com.wisp.app.nostr.EncryptedMedia
import io.objectbox.Box
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
/**
* Persists decrypted NIP-17 DMs so we don't re-decrypt every gift wrap on each cold start.
* Critical for remote signer (Amber) mode where each unwrap requires two IPC round-trips.
*/
class DmPersistence {
private val box: Box<DmMessageEntity> = WispObjectBox.store.boxFor(DmMessageEntity::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val writeChannel = Channel<Pair<String, DmMessage>>(Channel.BUFFERED)
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class ReactionDto(val authorPubkey: String, val emoji: String, val timestamp: Long, val emojiUrl: String? = null)
@Serializable
private data class ZapDto(val zapperPubkey: String, val sats: Long, val timestamp: Long)
@Serializable
private data class FileMetadataDto(
val fileUrl: String,
val mimeType: String,
val algorithm: String,
val keyHex: String,
val nonceHex: String,
val encryptedHash: String,
val originalHash: String,
val size: Long?,
val dimensions: String?,
val thumbhash: String?,
val blurhash: String?
)
init {
// Batched write-behind: collect over a short window then bulk-put
scope.launch {
val batch = mutableListOf<DmMessageEntity>()
for (item in writeChannel) {
batch.add(toEntity(item.first, item.second))
while (true) {
val next = writeChannel.tryReceive().getOrNull() ?: break
batch.add(toEntity(next.first, next.second))
}
if (batch.size < 50) {
delay(200)
while (true) {
val next = writeChannel.tryReceive().getOrNull() ?: break
batch.add(toEntity(next.first, next.second))
}
}
try {
// Resolve existing dbIds so puts are upserts (preserve identity per ownerPlusGiftWrap)
val resolved = batch.map { entity ->
val existingId = box.query(DmMessageEntity_.ownerPlusGiftWrap.equal(entity.ownerPlusGiftWrap))
.build().use { it.findFirst()?.dbId ?: 0L }
entity.copy(dbId = existingId)
}
box.put(resolved)
} catch (e: Exception) {
Log.w("DmPersistence", "Batch write failed: ${e.message}")
}
batch.clear()
}
}
}
/** Queue an upsert. Safe to call repeatedly — coalesced under a 200ms window. */
fun queueMessage(ownerPubkey: String, msg: DmMessage) {
if (ownerPubkey.isBlank() || msg.giftWrapId.isBlank()) return
writeChannel.trySend(ownerPubkey to msg)
}
/** Load every persisted DM for an account. Caller groups by conversation. */
fun loadAll(ownerPubkey: String): List<Pair<String, DmMessage>> {
if (ownerPubkey.isBlank()) return emptyList()
return try {
val entities = box.query(DmMessageEntity_.ownerPubkey.equal(ownerPubkey))
.build().use { it.find() }
entities.mapNotNull { it.toDmMessage()?.let { msg -> it.conversationKey to msg } }
} catch (e: Exception) {
Log.w("DmPersistence", "loadAll failed: ${e.message}")
emptyList()
}
}
/** Wipe a single account's DMs (e.g. on logout / [DmRepository.clear]). */
fun deleteAllForOwner(ownerPubkey: String) {
if (ownerPubkey.isBlank()) return
scope.launch {
try {
box.query(DmMessageEntity_.ownerPubkey.equal(ownerPubkey))
.build().use { it.remove() }
} catch (e: Exception) {
Log.w("DmPersistence", "deleteAllForOwner failed: ${e.message}")
}
}
}
/** Remove every conversation involving [pubkey] (mute / block flow). */
fun deleteConversationsWithPubkey(ownerPubkey: String, pubkey: String) {
if (ownerPubkey.isBlank() || pubkey.isBlank()) return
scope.launch {
try {
val entities = box.query(DmMessageEntity_.ownerPubkey.equal(ownerPubkey))
.build().use { it.find() }
val toRemove = entities.filter { it.conversationKey.split(",").contains(pubkey) }
if (toRemove.isNotEmpty()) box.remove(toRemove)
} catch (e: Exception) {
Log.w("DmPersistence", "deleteConversationsWithPubkey failed: ${e.message}")
}
}
}
private fun toEntity(ownerPubkey: String, msg: DmMessage): DmMessageEntity {
val reactionsJson = json.encodeToString(
ListSerializer(ReactionDto.serializer()),
msg.reactions.map { ReactionDto(it.authorPubkey, it.emoji, it.timestamp, it.emojiUrl) }
)
val zapsJson = json.encodeToString(
ListSerializer(ZapDto.serializer()),
msg.zaps.map { ZapDto(it.zapperPubkey, it.sats, it.timestamp) }
)
val fileJson = msg.encryptedFileMetadata?.let {
json.encodeToString(FileMetadataDto.serializer(), FileMetadataDto(
it.fileUrl, it.mimeType, it.algorithm, it.keyHex, it.nonceHex,
it.encryptedHash, it.originalHash, it.size, it.dimensions, it.thumbhash, it.blurhash
))
}
return DmMessageEntity(
ownerPubkey = ownerPubkey,
msgId = msg.id,
giftWrapId = msg.giftWrapId,
ownerPlusGiftWrap = "$ownerPubkey|${msg.giftWrapId}",
conversationKey = inferConversationKey(ownerPubkey, msg),
senderPubkey = msg.senderPubkey,
content = msg.content,
createdAt = msg.createdAt,
rumorId = msg.rumorId,
replyToId = msg.replyToId,
participantsJson = json.encodeToString(ListSerializer(String.serializer()), msg.participants),
relayUrlsJson = json.encodeToString(ListSerializer(String.serializer()), msg.relayUrls.toList()),
reactionsJson = reactionsJson,
zapsJson = zapsJson,
emojiMapJson = json.encodeToString(MapSerializer(String.serializer(), String.serializer()), msg.emojiMap),
encryptedFileMetadataJson = fileJson,
debugGiftWrapJson = msg.debugGiftWrapJson,
debugRumorJson = msg.debugRumorJson
)
}
private fun inferConversationKey(ownerPubkey: String, msg: DmMessage): String =
(msg.participants + ownerPubkey).toSortedSet().joinToString(",")
private fun DmMessageEntity.toDmMessage(): DmMessage? {
return try {
val participants: List<String> = json.decodeFromString(ListSerializer(String.serializer()), participantsJson)
val relayUrls: List<String> = json.decodeFromString(ListSerializer(String.serializer()), relayUrlsJson)
val reactions: List<ReactionDto> = json.decodeFromString(ListSerializer(ReactionDto.serializer()), reactionsJson)
val zaps: List<ZapDto> = json.decodeFromString(ListSerializer(ZapDto.serializer()), zapsJson)
val emojiMap: Map<String, String> = json.decodeFromString(
MapSerializer(String.serializer(), String.serializer()), emojiMapJson)
val fileMeta = encryptedFileMetadataJson?.let {
val dto: FileMetadataDto = json.decodeFromString(FileMetadataDto.serializer(), it)
EncryptedMedia.EncryptedFileMetadata(
dto.fileUrl, dto.mimeType, dto.algorithm, dto.keyHex, dto.nonceHex,
dto.encryptedHash, dto.originalHash, dto.size, dto.dimensions, dto.thumbhash, dto.blurhash
)
}
DmMessage(
id = msgId,
senderPubkey = senderPubkey,
content = content,
createdAt = createdAt,
giftWrapId = giftWrapId,
relayUrls = relayUrls.toSet(),
rumorId = rumorId,
replyToId = replyToId,
participants = participants,
reactions = reactions.map { DmReaction(it.authorPubkey, it.emoji, it.timestamp, it.emojiUrl) },
zaps = zaps.map { DmZap(it.zapperPubkey, it.sats, it.timestamp) },
emojiMap = emojiMap,
encryptedFileMetadata = fileMeta,
debugGiftWrapJson = debugGiftWrapJson,
debugRumorJson = debugRumorJson
)
} catch (e: Exception) {
Log.w("DmPersistence", "Failed to deserialize DM $giftWrapId: ${e.message}")
null
}
}
}
@@ -3,6 +3,7 @@ package com.wisp.app.repo
import android.content.Context
import android.content.SharedPreferences
import android.util.LruCache
import com.wisp.app.db.DmPersistence
import com.wisp.app.nostr.DmConversation
import com.wisp.app.nostr.DmMessage
import com.wisp.app.nostr.DmReaction
@@ -11,13 +12,21 @@ import com.wisp.app.nostr.NostrEvent
import com.wisp.app.nostr.wipe
import com.wisp.app.nostr.FlatNotificationItem
import com.wisp.app.nostr.NotificationType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
class DmRepository(private val context: Context? = null, pubkeyHex: String? = null) {
class DmRepository(
private val context: Context? = null,
pubkeyHex: String? = null,
private val persistence: DmPersistence? = null
) {
companion object {
/**
@@ -83,6 +92,108 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
private val _dmNotifications = MutableStateFlow<List<FlatNotificationItem>>(emptyList())
val dmNotifications: StateFlow<List<FlatNotificationItem>> = _dmNotifications
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
// Seeding queries ObjectBox — dispatch off the main thread so cold-start UI isn't blocked.
ioScope.launch { seedFromPersistence() }
}
/**
* Hydrate in-memory state from disk so we don't need to re-decrypt every gift wrap on
* each cold start. Populates [conversations], [seenGiftWraps] and [rumorIdIndex]; the
* existing dedup in [addPendingGiftWrap] then short-circuits relay-redelivered wraps
* before they hit the signer.
*/
private fun seedFromPersistence() {
val owner = myPubkey ?: return
val p = persistence ?: return
val loaded = p.loadAll(owner)
if (loaded.isEmpty()) return
var newestSeen = 0L
synchronized(lock) {
for ((convKey, msg) in loaded) {
if (seenGiftWraps.containsKey(msg.giftWrapId)) continue
seenGiftWraps[msg.giftWrapId] = msg.id
if (msg.rumorId.isNotEmpty()) {
rumorIdIndex[msg.rumorId] = Pair(convKey, msg.id)
}
if (msg.participants.isNotEmpty()) {
conversationParticipants[convKey] = msg.participants
}
val list = conversations.getOrPut(convKey) { mutableListOf() }
list.add(msg)
if (msg.createdAt > latestGiftWrapTs) latestGiftWrapTs = msg.createdAt
if (msg.createdAt > newestSeen) newestSeen = msg.createdAt
rebuildNotifItemsLocked(convKey, msg)
}
for ((_, list) in conversations) list.sortBy { it.createdAt }
val sorted = dmNotifItems.sortedByDescending { it.timestamp }
_dmNotifications.value = if (sorted.size > 200) sorted.take(200) else sorted
}
if (newestSeen > lastReadDmTimestamp) _hasUnreadDms.value = true
updateConversationList()
}
/**
* Rebuild incoming-DM, reaction and zap notification items for a single persisted message.
* Caller must hold [lock]. Used only for hydration the live `addMessage` path inlines
* the same logic with extra audio/haptic side-effects we want to skip on cold start.
*/
private fun rebuildNotifItemsLocked(convKey: String, msg: DmMessage) {
val owner = myPubkey ?: return
if (msg.senderPubkey != owner) {
val flatId = "dm:${msg.id}"
if (dmNotifIds.add(flatId)) {
val peerPubkey = msg.participants.firstOrNull() ?: convKey
dmNotifItems.add(FlatNotificationItem(
id = flatId,
type = NotificationType.DM,
actorPubkey = msg.senderPubkey,
referencedEventId = msg.id,
timestamp = msg.createdAt,
dmContent = msg.content,
dmPeerPubkey = peerPubkey,
dmRumorId = msg.rumorId.ifEmpty { null }
))
}
} else {
// Reactions and zaps appear in the notifications screen only for the user's own messages.
for (reaction in msg.reactions) {
val flatId = "dmreact:${reaction.authorPubkey}:${msg.rumorId.ifEmpty { msg.id }}"
if (dmNotifIds.add(flatId)) {
val peerPubkey = if (msg.participants.size > 1) convKey
else msg.participants.firstOrNull() ?: convKey
dmNotifItems.add(FlatNotificationItem(
id = flatId,
type = NotificationType.DM_REACTION,
actorPubkey = reaction.authorPubkey,
referencedEventId = msg.rumorId.ifEmpty { msg.id },
timestamp = reaction.timestamp,
emoji = reaction.emoji,
dmPeerPubkey = peerPubkey
))
}
}
for (zap in msg.zaps) {
val flatId = "dmzap:${zap.zapperPubkey}:${msg.rumorId.ifEmpty { msg.id }}"
if (dmNotifIds.add(flatId)) {
val peerPubkey = if (msg.participants.size > 1) convKey
else msg.participants.firstOrNull() ?: convKey
dmNotifItems.add(FlatNotificationItem(
id = flatId,
type = NotificationType.DM_ZAP,
actorPubkey = zap.zapperPubkey,
referencedEventId = msg.rumorId.ifEmpty { msg.id },
timestamp = zap.timestamp,
zapSats = zap.sats,
dmPeerPubkey = peerPubkey
))
}
}
}
}
fun markDecryptingStart() {
decryptingRefCount.incrementAndGet()
_decrypting.value = true
@@ -145,6 +256,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
messages.add(msg)
messages.sortBy { it.createdAt }
}
myPubkey?.let { persistence?.queueMessage(it, msg) }
if (isNewIncoming) {
_dmReceived.tryEmit(Unit)
}
@@ -159,6 +271,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
* in conversation [convKey].
*/
fun addZap(convKey: String, messageId: String, zap: DmZap) {
var updated: DmMessage? = null
synchronized(lock) {
val messages = conversations.get(convKey) ?: return
val idx = messages.indexOfFirst { it.rumorId == messageId || it.id == messageId }
@@ -167,6 +280,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
// Dedupe by zapperPubkey + timestamp
if (existing.zaps.any { it.zapperPubkey == zap.zapperPubkey && it.timestamp == zap.timestamp }) return
messages[idx] = existing.copy(zaps = existing.zaps + zap)
updated = messages[idx]
// Notify if the original message was sent by the local user
val isMine = myPubkey != null && existing.senderPubkey == myPubkey
@@ -189,6 +303,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
}
}
}
updated?.let { msg -> myPubkey?.let { persistence?.queueMessage(it, msg) } }
updateConversationList()
}
@@ -197,6 +312,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
* If the original message was sent by the local user, also emits a DM_REACTION notification.
*/
fun addReaction(convKey: String, messageId: String, reaction: DmReaction) {
var updated: DmMessage? = null
synchronized(lock) {
val messages = conversations.get(convKey) ?: return
val idx = messages.indexOfFirst { it.rumorId == messageId || it.id == messageId }
@@ -207,6 +323,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
}
if (alreadyReacted) return
messages[idx] = existing.copy(reactions = existing.reactions + reaction)
updated = messages[idx]
// Notify if the original message was sent by the local user
val isMine = myPubkey != null && existing.senderPubkey == myPubkey
@@ -231,6 +348,7 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
}
}
}
updated?.let { msg -> myPubkey?.let { persistence?.queueMessage(it, msg) } }
updateConversationList()
}
@@ -308,16 +426,34 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
synchronized(lock) {
// Remove all conversations that include this pubkey
val keysToRemove = conversations.keys.filter { key -> key.split(",").contains(pubkey) }
keysToRemove.forEach {
conversations.remove(it)
conversationParticipants.remove(it)
keysToRemove.forEach { convKey ->
conversations.remove(convKey)?.forEach { msg ->
seenGiftWraps.remove(msg.giftWrapId)
if (msg.rumorId.isNotEmpty()) rumorIdIndex.remove(msg.rumorId)
}
conversationParticipants.remove(convKey)
}
conversationKeyCache.remove(pubkey)
}
myPubkey?.let { persistence?.deleteConversationsWithPubkey(it, pubkey) }
updateConversationList()
}
fun addPendingGiftWrap(event: NostrEvent, relayUrl: String) {
// Already-decrypted (and persisted) wraps must skip the queue — otherwise we'd
// re-hit the signer for every wrap on every cold start.
if (seenGiftWraps.containsKey(event.id)) {
if (relayUrl.isNotEmpty()) {
synchronized(lock) {
val msgId = seenGiftWraps[event.id] ?: return@synchronized
val convKey = conversations.entries.firstOrNull { (_, msgs) ->
msgs.any { it.id == msgId }
}?.key ?: return@synchronized
mergeRelayUrlsLocked(convKey, msgId, setOf(relayUrl))
}
}
return
}
synchronized(pendingLock) {
// Dedup by event id
if (pendingGiftWraps.any { it.event.id == event.id }) return
@@ -351,9 +487,14 @@ class DmRepository(private val context: Context? = null, pubkeyHex: String? = nu
prefs = context?.getSharedPreferences("wisp_dm_$pubkeyHex", Context.MODE_PRIVATE)
lastReadDmTimestamp = prefs?.getLong("last_read_dm", 0L) ?: 0L
latestGiftWrapTs = prefs?.getLong("latest_gwrap_ts", 0L) ?: 0L
// Hydrate decrypted DMs for the new account so we don't re-decrypt on switch.
ioScope.launch { seedFromPersistence() }
}
fun clear() {
// Wipe persistence for the previous owner — clear() is invoked on logout / account
// switch where keeping decrypted DMs around would be wrong.
myPubkey?.let { persistence?.deleteAllForOwner(it) }
synchronized(lock) {
conversations.clear()
conversationParticipants.clear()
@@ -174,6 +174,9 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
val eventPersistence: EventPersistence? = if (WispObjectBox.isInitialized) {
EventPersistence(pubkeyHex)
} else null
val dmPersistence: com.wisp.app.db.DmPersistence? = if (WispObjectBox.isInitialized) {
com.wisp.app.db.DmPersistence()
} else null
val eventRepo = EventRepository(profileRepo, muteRepo, relayHintStore).also {
it.currentUserPubkey = pubkeyHex
it.deletedEventsRepo = deletedEventsRepo
@@ -183,7 +186,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
eventRepo.contactRepo = it
}
val listRepo = ListRepository(app, pubkeyHex)
val dmRepo = DmRepository(app, pubkeyHex)
val dmRepo = DmRepository(app, pubkeyHex, dmPersistence)
val groupRepo = GroupRepository(app, pubkeyHex)
val liveStreamRepo = LiveStreamRepository()
val notifRepo = NotificationRepository(app, pubkeyHex, muteRepo, eventRepo)