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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user