mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
persistent
This commit is contained in:
parent
7fad2f2202
commit
d3f01f27c6
File diff suppressed because it is too large
Load Diff
135
app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt
Normal file
135
app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt
Normal file
@ -0,0 +1,135 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.PrivateMessageArrivalOrder
|
||||
|
||||
/**
|
||||
* Presence-independent presentation state for every retained private conversation.
|
||||
*/
|
||||
internal data class ConversationSummary(
|
||||
val conversationID: String,
|
||||
val displayName: String,
|
||||
val unreadCount: Int,
|
||||
val latestMessageAt: Long,
|
||||
val latestActivityOrder: Long,
|
||||
val latestMessageType: BitchatMessageType,
|
||||
val latestMessagePreview: String,
|
||||
val transport: DirectMessageTransport,
|
||||
val nostrPubkey: String?,
|
||||
val identityAliases: Set<String>,
|
||||
val isConnected: Boolean = false,
|
||||
val connectedPeerID: String? = null,
|
||||
val sourceGeohash: String? = null
|
||||
)
|
||||
|
||||
internal fun buildConversationSummaries(
|
||||
unreadConversationIDs: Set<String>,
|
||||
privateChats: Map<String, List<BitchatMessage>>,
|
||||
currentUserIdentifiers: Set<String>,
|
||||
canonicalize: (String) -> String,
|
||||
isMessageRead: (BitchatMessage) -> Boolean
|
||||
): List<ConversationSummary> {
|
||||
if (privateChats.isEmpty()) return emptyList()
|
||||
|
||||
val currentUsers = currentUserIdentifiers.filterTo(mutableSetOf()) { it.isNotBlank() }
|
||||
val unreadCanonicalIDs = unreadConversationIDs
|
||||
.mapTo(mutableSetOf()) { canonicalize(it).lowercase() }
|
||||
val aliasesByCanonicalID = linkedMapOf<String, MutableSet<String>>()
|
||||
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
|
||||
|
||||
privateChats.forEach { (sourceID, messages) ->
|
||||
val canonicalID = canonicalize(sourceID)
|
||||
aliasesByCanonicalID
|
||||
.getOrPut(canonicalID) { linkedSetOf() }
|
||||
.add(sourceID)
|
||||
messagesByCanonicalID
|
||||
.getOrPut(canonicalID) { mutableListOf() }
|
||||
.addAll(messages)
|
||||
}
|
||||
|
||||
return messagesByCanonicalID.mapNotNull { (conversationID, sourceMessages) ->
|
||||
val messages = sourceMessages.distinctBy { it.id }
|
||||
if (messages.isEmpty()) return@mapNotNull null
|
||||
|
||||
fun activityOrder(message: BitchatMessage): Long =
|
||||
PrivateMessageArrivalOrder.sequenceOf(message.id) ?: message.timestamp.time
|
||||
|
||||
val latest = messages.maxWithOrNull(
|
||||
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
|
||||
) ?: return@mapNotNull null
|
||||
val incoming = messages.filterNot {
|
||||
it.sender in currentUsers || it.sender == "system"
|
||||
}
|
||||
val latestIncoming = incoming.maxWithOrNull(
|
||||
compareBy<BitchatMessage>(::activityOrder).thenBy { it.id }
|
||||
)
|
||||
val canonicalUnread = conversationID.lowercase() in unreadCanonicalIDs
|
||||
val unreadCount = if (canonicalUnread) {
|
||||
incoming.count { !isMessageRead(it) }.coerceAtLeast(1)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val aliases = aliasesByCanonicalID[conversationID].orEmpty()
|
||||
val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey
|
||||
val isNostrConversation = nostrPubkey != null ||
|
||||
aliases.any(::isNostrConversationKey) ||
|
||||
isNostrConversationKey(conversationID)
|
||||
val displayName = latestIncoming
|
||||
?.sender
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: latest.recipientNickname
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: latest.sender.takeIf {
|
||||
it.isNotBlank() && it !in currentUsers && it != "system"
|
||||
}
|
||||
?: conversationID.take(12)
|
||||
|
||||
ConversationSummary(
|
||||
conversationID = conversationID,
|
||||
displayName = displayName,
|
||||
unreadCount = unreadCount,
|
||||
latestMessageAt = latest.timestamp.time,
|
||||
latestActivityOrder = activityOrder(latest),
|
||||
latestMessageType = latest.type,
|
||||
latestMessagePreview = latest.conversationPreview(),
|
||||
transport = if (isNostrConversation) {
|
||||
DirectMessageTransport.NOSTR
|
||||
} else {
|
||||
DirectMessageTransport.MESH
|
||||
},
|
||||
nostrPubkey = nostrPubkey,
|
||||
identityAliases = (aliases + conversationID)
|
||||
.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sortConversationSummaries(
|
||||
conversations: List<ConversationSummary>
|
||||
): List<ConversationSummary> = conversations.sortedWith(
|
||||
compareByDescending<ConversationSummary> { it.isConnected }
|
||||
.thenByDescending { it.unreadCount > 0 }
|
||||
.thenByDescending { it.latestActivityOrder }
|
||||
.thenBy { it.displayName.lowercase() }
|
||||
.thenBy { it.conversationID }
|
||||
)
|
||||
|
||||
private fun isNostrConversationKey(value: String): Boolean =
|
||||
value.startsWith("nostr_") || value.startsWith("nostr:")
|
||||
|
||||
private fun BitchatMessage.conversationPreview(): String {
|
||||
val preview = when (type) {
|
||||
BitchatMessageType.File -> content
|
||||
.substringAfterLast('/')
|
||||
.substringAfterLast('\\')
|
||||
else -> content
|
||||
}
|
||||
return preview
|
||||
.replace(CONVERSATION_PREVIEW_WHITESPACE, " ")
|
||||
.trim()
|
||||
.take(MAX_CONVERSATION_PREVIEW_LENGTH)
|
||||
}
|
||||
|
||||
private val CONVERSATION_PREVIEW_WHITESPACE = Regex("\\s+")
|
||||
private const val MAX_CONVERSATION_PREVIEW_LENGTH = 240
|
||||
@ -0,0 +1,67 @@
|
||||
package com.bitchat.android.features.file
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FileUtilsConversationCleanupTest {
|
||||
|
||||
@Test
|
||||
fun `deleting conversation removes only unreferenced app-owned media`() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val testDirectory = File(
|
||||
context.cacheDir,
|
||||
"conversation-cleanup-${UUID.randomUUID()}"
|
||||
).apply { mkdirs() }
|
||||
val deletedOnly = File(testDirectory, "deleted.jpg").apply {
|
||||
writeBytes(byteArrayOf(1))
|
||||
}
|
||||
val shared = File(testDirectory, "shared.jpg").apply {
|
||||
writeBytes(byteArrayOf(2))
|
||||
}
|
||||
val outsideAppStorage = File.createTempFile(
|
||||
"conversation-cleanup-",
|
||||
".jpg"
|
||||
).apply {
|
||||
writeBytes(byteArrayOf(3))
|
||||
}
|
||||
|
||||
try {
|
||||
FileUtils.deleteConversationMedia(
|
||||
context = context,
|
||||
deletedMessages = listOf(
|
||||
mediaMessage("deleted", deletedOnly),
|
||||
mediaMessage("shared-deleted", shared),
|
||||
mediaMessage("outside", outsideAppStorage)
|
||||
),
|
||||
retainedMessages = listOf(mediaMessage("shared-retained", shared))
|
||||
)
|
||||
|
||||
assertFalse(deletedOnly.exists())
|
||||
assertTrue(shared.exists())
|
||||
assertTrue(outsideAppStorage.exists())
|
||||
} finally {
|
||||
testDirectory.deleteRecursively()
|
||||
outsideAppStorage.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaMessage(id: String, file: File) = BitchatMessage(
|
||||
id = id,
|
||||
sender = "alice",
|
||||
content = file.absolutePath,
|
||||
type = BitchatMessageType.Image,
|
||||
timestamp = Date(1L),
|
||||
isPrivate = true
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConversationDatabaseTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var databaseName: String
|
||||
private lateinit var database: ConversationDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
databaseName = "conversation-test-${UUID.randomUUID()}.db"
|
||||
database = ConversationDatabase(context, databaseName)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message fields read state and delivery status survive reopen`() {
|
||||
val message = BitchatMessage(
|
||||
id = "round-trip",
|
||||
sender = "alice",
|
||||
content = "photo path",
|
||||
type = BitchatMessageType.Image,
|
||||
timestamp = Date(123_456L),
|
||||
isRelay = true,
|
||||
originalSender = "alice-original",
|
||||
isPrivate = true,
|
||||
recipientNickname = "me",
|
||||
senderPeerID = "0123456789abcdef",
|
||||
mentions = listOf("me", "bob"),
|
||||
channel = "private",
|
||||
encryptedContent = byteArrayOf(1, 2, 3),
|
||||
isEncrypted = true,
|
||||
deliveryStatus = DeliveryStatus.Delivered("me", Date(123_999L)),
|
||||
senderNostrPubkey = "a".repeat(64)
|
||||
)
|
||||
|
||||
database.upsertMessage(
|
||||
conversationID = "contact_alice",
|
||||
aliases = setOf("contact_alice", "0123456789abcdef"),
|
||||
displayName = "alice",
|
||||
message = message,
|
||||
isRead = false
|
||||
)
|
||||
database.markRead(message.id)
|
||||
database.updateDeliveryStatus(
|
||||
message.id,
|
||||
DeliveryStatus.Read("me", Date(124_000L))
|
||||
)
|
||||
database.close()
|
||||
|
||||
database = ConversationDatabase(context, databaseName)
|
||||
val restored = database.loadSnapshot()
|
||||
val restoredMessage = restored.chats.getValue("contact_alice").single()
|
||||
|
||||
assertEquals(message.id, restoredMessage.id)
|
||||
assertEquals(message.sender, restoredMessage.sender)
|
||||
assertEquals(message.content, restoredMessage.content)
|
||||
assertEquals(message.type, restoredMessage.type)
|
||||
assertEquals(message.timestamp, restoredMessage.timestamp)
|
||||
assertEquals(message.mentions, restoredMessage.mentions)
|
||||
assertEquals(message.senderNostrPubkey, restoredMessage.senderNostrPubkey)
|
||||
assertArrayEquals(message.encryptedContent, restoredMessage.encryptedContent)
|
||||
assertEquals(
|
||||
DeliveryStatus.Read("me", Date(124_000L)),
|
||||
restoredMessage.deliveryStatus
|
||||
)
|
||||
assertEquals(setOf(message.id), restored.readMessageIDs)
|
||||
assertEquals(listOf(message.id), restored.arrivalOrder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aliases merge into one conversation and duplicate transport delivery stays unique`() {
|
||||
val first = message("first", "alice", 100L)
|
||||
val second = message("second", "alice", 200L)
|
||||
|
||||
database.upsertMessage("mesh-alias", setOf("mesh-alias"), "alice", first, false)
|
||||
database.upsertMessage("nostr_alias", setOf("nostr_alias"), "alice", second, false)
|
||||
database.mergeAliases(
|
||||
targetConversationID = "contact_alice",
|
||||
aliases = setOf("mesh-alias", "nostr_alias", "contact_alice")
|
||||
)
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "nostr_alias"),
|
||||
"alice",
|
||||
first,
|
||||
false
|
||||
)
|
||||
|
||||
val restored = database.loadSnapshot()
|
||||
assertEquals(setOf("contact_alice"), restored.chats.keys)
|
||||
assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversation deletion cascades messages while a later message starts fresh`() {
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice"),
|
||||
"alice",
|
||||
message("old", "alice", 100L),
|
||||
false
|
||||
)
|
||||
|
||||
database.deleteConversation(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice")
|
||||
)
|
||||
assertTrue(database.loadSnapshot().chats.isEmpty())
|
||||
|
||||
database.close()
|
||||
database = ConversationDatabase(context, databaseName)
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice"),
|
||||
"alice",
|
||||
message("old", "alice", 100L),
|
||||
false
|
||||
)
|
||||
assertTrue(database.loadSnapshot().chats.isEmpty())
|
||||
|
||||
database.upsertMessage(
|
||||
"contact_alice",
|
||||
setOf("mesh-alias", "contact_alice"),
|
||||
"alice",
|
||||
message("new", "alice", 200L),
|
||||
false
|
||||
)
|
||||
val restored = database.loadSnapshot()
|
||||
assertEquals(listOf("new"), restored.chats.getValue("contact_alice").map { it.id })
|
||||
assertFalse(restored.readMessageIDs.contains("old"))
|
||||
assertTrue(restored.deletedMessageIDs.contains("old"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per conversation retention keeps the newest bounded history`() {
|
||||
val total = ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION + 5
|
||||
repeat(total) { index ->
|
||||
database.upsertMessage(
|
||||
conversationID = "contact_alice",
|
||||
aliases = setOf("contact_alice"),
|
||||
displayName = "alice",
|
||||
message = message("message-$index", "alice", index.toLong()),
|
||||
isRead = true
|
||||
)
|
||||
}
|
||||
|
||||
val snapshot = database.loadSnapshot()
|
||||
val messages = snapshot.chats.getValue("contact_alice")
|
||||
assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, messages.size)
|
||||
assertEquals("message-5", messages.first().id)
|
||||
assertEquals("message-${total - 1}", messages.last().id)
|
||||
assertEquals(
|
||||
(0 until 5).mapTo(mutableSetOf()) { "message-$it" },
|
||||
snapshot.deletedMessageIDs
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global retention is hard bounded when every conversation has one message`() {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
database = ConversationDatabase(
|
||||
context = context,
|
||||
databaseName = databaseName,
|
||||
maxMessagesPerConversation = 10,
|
||||
maxMessagesTotal = 3,
|
||||
maxPayloadBytes = Long.MAX_VALUE
|
||||
)
|
||||
repeat(4) { index ->
|
||||
database.upsertMessage(
|
||||
conversationID = "peer-$index",
|
||||
aliases = setOf("peer-$index"),
|
||||
displayName = "peer $index",
|
||||
message = message("single-$index", "peer $index", index.toLong()),
|
||||
isRead = true
|
||||
)
|
||||
}
|
||||
|
||||
database.pruneToRetentionLimits()
|
||||
|
||||
val snapshot = database.loadSnapshot()
|
||||
assertEquals(3, snapshot.chats.values.sumOf { it.size })
|
||||
assertFalse(snapshot.chats.containsKey("peer-0"))
|
||||
assertTrue(snapshot.deletedMessageIDs.contains("single-0"))
|
||||
}
|
||||
|
||||
private fun message(id: String, sender: String, timestamp: Long) = BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = "hello-$id",
|
||||
timestamp = Date(timestamp),
|
||||
isPrivate = true,
|
||||
senderPeerID = "0123456789abcdef"
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConversationRepositoryTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var databaseName: String
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val dispatcher = executor.asCoroutineDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
databaseName = "conversation-repository-test-${UUID.randomUUID()}.db"
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
dispatcher.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reload restores persisted history after initial process restore`() {
|
||||
val repository = ConversationRepository(
|
||||
context = context,
|
||||
dispatcher = dispatcher,
|
||||
databaseName = databaseName
|
||||
)
|
||||
val message = BitchatMessage(
|
||||
id = "persisted-message",
|
||||
sender = "alice",
|
||||
content = "survives restart",
|
||||
timestamp = Date(100L),
|
||||
isPrivate = true
|
||||
)
|
||||
repository.upsertMessage(
|
||||
conversationID = "peer-alice",
|
||||
aliases = setOf("peer-alice"),
|
||||
displayName = "alice",
|
||||
message = message,
|
||||
isRead = true
|
||||
)
|
||||
runBlocking { repository.awaitPendingWrites() }
|
||||
|
||||
val initialSnapshot = AtomicReference<PersistedConversationSnapshot>()
|
||||
repository.initialize(initialSnapshot::set)
|
||||
runBlocking { repository.awaitPendingWrites() }
|
||||
assertEquals(
|
||||
listOf(message),
|
||||
initialSnapshot.get().chats.getValue("peer-alice")
|
||||
)
|
||||
|
||||
val reloadedSnapshot = AtomicReference<PersistedConversationSnapshot>()
|
||||
repository.reload(reloadedSnapshot::set)
|
||||
runBlocking { repository.awaitPendingWrites() }
|
||||
assertEquals(
|
||||
listOf(message),
|
||||
reloadedSnapshot.get().chats.getValue("peer-alice")
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.PrivateMessageArrivalOrder
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class ConversationSummaryTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
PrivateMessageArrivalOrder.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read offline conversation remains present`() {
|
||||
val message = incoming("message", "alice", 100L)
|
||||
PrivateMessageArrivalOrder.record(message.id)
|
||||
|
||||
val conversations = buildConversationSummaries(
|
||||
unreadConversationIDs = emptySet(),
|
||||
privateChats = mapOf("alice-peer" to listOf(message)),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { true }
|
||||
)
|
||||
|
||||
assertEquals(1, conversations.size)
|
||||
assertEquals("alice-peer", conversations.single().conversationID)
|
||||
assertEquals(0, conversations.single().unreadCount)
|
||||
assertFalse(conversations.single().isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical aliases yield one conversation with unique messages`() {
|
||||
val first = incoming("first", "alice", 300L)
|
||||
val second = incoming("second", "alice", 100L)
|
||||
PrivateMessageArrivalOrder.record(first.id)
|
||||
PrivateMessageArrivalOrder.record(second.id)
|
||||
|
||||
val conversations = buildConversationSummaries(
|
||||
unreadConversationIDs = setOf("mesh-alias"),
|
||||
privateChats = mapOf(
|
||||
"mesh-alias" to listOf(first),
|
||||
"nostr_alias" to listOf(first, second)
|
||||
),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { "contact_alice" },
|
||||
isMessageRead = { false }
|
||||
)
|
||||
|
||||
assertEquals(1, conversations.size)
|
||||
assertEquals(2, conversations.single().unreadCount)
|
||||
assertEquals(
|
||||
setOf("mesh-alias", "nostr_alias", "contact_alice"),
|
||||
conversations.single().identityAliases
|
||||
)
|
||||
assertEquals("alice", conversations.single().displayName)
|
||||
assertEquals(DirectMessageTransport.NOSTR, conversations.single().transport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online conversations sort before newer offline conversations`() {
|
||||
val offline = summary(
|
||||
id = "offline",
|
||||
isConnected = false,
|
||||
unreadCount = 3,
|
||||
activity = 300L
|
||||
)
|
||||
val onlineRead = summary(
|
||||
id = "online-read",
|
||||
isConnected = true,
|
||||
unreadCount = 0,
|
||||
activity = 100L
|
||||
)
|
||||
val onlineUnread = summary(
|
||||
id = "online-unread",
|
||||
isConnected = true,
|
||||
unreadCount = 1,
|
||||
activity = 50L
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("online-unread", "online-read", "offline"),
|
||||
sortConversationSummaries(listOf(offline, onlineRead, onlineUnread))
|
||||
.map { it.conversationID }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview follows local arrival order instead of remote timestamp`() {
|
||||
val first = incoming(
|
||||
id = "first",
|
||||
sender = "alice",
|
||||
timestamp = 900L,
|
||||
content = "older arrival"
|
||||
)
|
||||
val second = incoming(
|
||||
id = "second",
|
||||
sender = "alice",
|
||||
timestamp = 100L,
|
||||
content = " latest\nmessage "
|
||||
)
|
||||
PrivateMessageArrivalOrder.record(first.id)
|
||||
PrivateMessageArrivalOrder.record(second.id)
|
||||
|
||||
val conversation = buildConversationSummaries(
|
||||
unreadConversationIDs = emptySet(),
|
||||
privateChats = mapOf("alice-peer" to listOf(first, second)),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { true }
|
||||
).single()
|
||||
|
||||
assertEquals("latest message", conversation.latestMessagePreview)
|
||||
assertEquals(BitchatMessageType.Message, conversation.latestMessageType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file preview contains filename without local path`() {
|
||||
val message = incoming(
|
||||
id = "file",
|
||||
sender = "alice",
|
||||
timestamp = 100L,
|
||||
content = "/private/conversations/quarterly report.pdf",
|
||||
type = BitchatMessageType.File
|
||||
)
|
||||
|
||||
val conversation = buildConversationSummaries(
|
||||
unreadConversationIDs = emptySet(),
|
||||
privateChats = mapOf("alice-peer" to listOf(message)),
|
||||
currentUserIdentifiers = setOf("me"),
|
||||
canonicalize = { it },
|
||||
isMessageRead = { true }
|
||||
).single()
|
||||
|
||||
assertEquals("quarterly report.pdf", conversation.latestMessagePreview)
|
||||
assertEquals(BitchatMessageType.File, conversation.latestMessageType)
|
||||
}
|
||||
|
||||
private fun incoming(
|
||||
id: String,
|
||||
sender: String,
|
||||
timestamp: Long,
|
||||
content: String = "hello",
|
||||
type: BitchatMessageType = BitchatMessageType.Message
|
||||
) = BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = content,
|
||||
type = type,
|
||||
timestamp = Date(timestamp),
|
||||
isPrivate = true
|
||||
)
|
||||
|
||||
private fun summary(
|
||||
id: String,
|
||||
isConnected: Boolean,
|
||||
unreadCount: Int,
|
||||
activity: Long
|
||||
) = ConversationSummary(
|
||||
conversationID = id,
|
||||
displayName = id,
|
||||
unreadCount = unreadCount,
|
||||
latestMessageAt = activity,
|
||||
latestActivityOrder = activity,
|
||||
latestMessageType = BitchatMessageType.Message,
|
||||
latestMessagePreview = "hello",
|
||||
transport = DirectMessageTransport.MESH,
|
||||
nostrPubkey = null,
|
||||
identityAliases = setOf(id),
|
||||
isConnected = isConnected
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user