Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An index derived from decrypted history is plaintext by another name: it would have to be sealed as carefully as the vault, taken by forgetEverything, and kept consistent with the threads — a second copy of everything anybody ever wrote, with its own ways to go wrong. This reads what already exists and throws the plaintext away. ⚠️ It costs more here than on iOS, and the code says so. The iPhone app keeps the whole decrypted history in memory; Android decrypts each thread on demand. Hence a two-character minimum, a bound, a 250ms debounce, and Dispatchers.IO. 🔴 Folding is length-preserving on purpose: the snippet slices the ORIGINAL string at an index found in the folded one, so a fold that dropped characters would cut in the wrong place — and for an accented character, mid-character. There is a test with accents before the match. Accent-insensitive because somebody typing "cafe" means "café", and a search that knows the difference quietly fails in every language with accents — Spanish is next on the localisation list. Refs paulhitt/flamenet-messenger-ios#25 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a0a061a34
commit
42b7d1732f
@@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -131,6 +132,11 @@ fun ColumnScope.MessagesScreen(
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
// ⚠️ Above the conversation list. Somebody searching for a word they
|
||||
// remember is looking for the message, not the person — name matches are
|
||||
// already covered by the list underneath.
|
||||
MessageHits(model, query.value)
|
||||
|
||||
BuddyCirclesRow(model, phase)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Divider()
|
||||
@@ -294,3 +300,52 @@ private fun UnreadDot() {
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a search found inside conversations, rather than in their names.
|
||||
*
|
||||
* ⚠️ Debounced. Each keystroke would otherwise decrypt every thread on the
|
||||
* device — the cost the iPhone app does not pay, because it keeps the whole
|
||||
* history in memory.
|
||||
*/
|
||||
@Composable
|
||||
private fun MessageHits(model: MessengerViewModel, query: String) {
|
||||
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.trim().length < io.flamenet.e2e.MessageSearch.MINIMUM_QUERY) {
|
||||
hits = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
kotlinx.coroutines.delay(250)
|
||||
hits = model.searchMessages(query)
|
||||
}
|
||||
|
||||
if (hits.isEmpty()) return
|
||||
|
||||
Text(
|
||||
"IN MESSAGES",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Palette.SecondaryText,
|
||||
letterSpacing = 0.8.sp,
|
||||
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
|
||||
)
|
||||
for (hit in hits) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = Space.md, vertical = Space.sm)
|
||||
.testTag("search-hit")
|
||||
) {
|
||||
Text(
|
||||
hit.snippet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Divider()
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
}
|
||||
|
||||
@@ -2225,6 +2225,29 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
viewModelScope.launch(Dispatchers.IO) { vault.saveDraft(chat.vaultThreadId(), text) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages whose words match, across every conversation on this device.
|
||||
*
|
||||
* 🔴 **Scanned, not indexed.** An index over the decrypted history is
|
||||
* plaintext by another name and would have to be sealed and taken by
|
||||
* `forgetEverything` like everything else in the vault. See
|
||||
* [io.flamenet.e2e.MessageSearch] for the condition under which that stops
|
||||
* being the right call.
|
||||
*
|
||||
* ⚠️ Off the main thread, because unlike the iPhone app this decrypts each
|
||||
* thread on demand rather than reading one already in memory.
|
||||
*
|
||||
* ⚠️ The query is never persisted. What somebody searched for is often more
|
||||
* revealing than what they found.
|
||||
*/
|
||||
suspend fun searchMessages(query: String): List<io.flamenet.e2e.MessageSearch.Hit> {
|
||||
val vault = vault ?: return emptyList()
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching { io.flamenet.e2e.MessageSearch.hits(query, vault) }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
|
||||
Reference in New Issue
Block a user