Stickers and emoji in the composer (Android), and a written queue
Stickers send, arrive, file into the right thread - group messages under the group, not scattered across one thread per member - and draw bare. No bubble: the kit draws none, and a glyph inside a grey rounded rectangle reads as a broken image. The timestamp and avatar stay, because that is the line saying who and when. Two controls, not one, because they are two different things. An emoji goes INTO the draft and is indistinguishable from any other character once sent - nothing on the receiving end needs to understand it. A sticker is its own message kind that is never rendered as text. Behind one button they would imply they behave the same way. Hence also: the emoji panel stays open on tap, since picking three in a row should put three characters in the box, while a sticker sends and closes because it IS the message. The emoji set is a short curated list, said plainly in the code: a full picker is search, skin tones, categories and recents - a real feature the system keyboard already ships. This is what a thumb can reach. docs/ROADMAP.md now holds the queue, including the security and privacy work. Written down because this project has already lost a finished feature to a parked branch. Two items there are real and current: Android spends a one-time prekey on EVERY message via fetchKeys, silently costing initial forward secrecy once the pool drains - iOS fixed this and Android has the unused listDevices sitting next to it - and a passphrase IS the account, with no second factor and no recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b195c730c
commit
85d6825fdf
@@ -1235,6 +1235,9 @@ fun ColumnScope.ChatScreen(
|
||||
// row. The kit's Message Box is one row — plus, the pill, then icons — and
|
||||
// a voice control on a line of its own underneath was the last thing on
|
||||
// this screen that did not belong to that row.
|
||||
var showStickers by remember { mutableStateOf(false) }
|
||||
var showEmoji by remember { mutableStateOf(false) }
|
||||
|
||||
var micGranted by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
@@ -1307,6 +1310,24 @@ fun ColumnScope.ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ⚠️ **Two controls, not one, because they are two different things.**
|
||||
// An emoji goes INTO the text and is indistinguishable from any other
|
||||
// character once sent. A sticker is its own message kind that is never
|
||||
// rendered as text. Putting them behind one button would imply they
|
||||
// behave the same way, and they do not — see docs/STICKERS.md.
|
||||
IconAction(
|
||||
icon = KitIcon.Emoji,
|
||||
label = if (showEmoji) "Close emoji" else "Emoji",
|
||||
tag = "emoji",
|
||||
onClick = { showEmoji = !showEmoji; if (showEmoji) showStickers = false },
|
||||
)
|
||||
IconAction(
|
||||
icon = KitIcon.Sticker,
|
||||
label = if (showStickers) "Close stickers" else "Stickers",
|
||||
tag = "stickers",
|
||||
onClick = { showStickers = !showStickers; if (showStickers) showEmoji = false },
|
||||
)
|
||||
|
||||
if (chat.peerId != null && chat.recordingSeconds == null) {
|
||||
IconAction(
|
||||
icon = KitIcon.Mic,
|
||||
@@ -1355,6 +1376,25 @@ fun ColumnScope.ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showEmoji) {
|
||||
EmojiPicker { emoji ->
|
||||
// Into the draft, not sent — an emoji is a character, and somebody
|
||||
// picking three in a row expects three in the box, not three
|
||||
// messages. Closing the panel on each tap would make that
|
||||
// impossible, so it stays open.
|
||||
draft += emoji
|
||||
}
|
||||
}
|
||||
|
||||
if (showStickers) {
|
||||
StickerPicker { sticker ->
|
||||
// A sticker IS the message. There is nothing to add it to and
|
||||
// nothing further to say, so it sends and the panel closes.
|
||||
model.sendSticker(chat, sticker.id)
|
||||
showStickers = false
|
||||
}
|
||||
}
|
||||
|
||||
// Voice notes sit under the composer rather than inside it: recording
|
||||
// replaces the row with its own controls, and squeezing a fourth control
|
||||
// into a row that already has three makes every one of them too small.
|
||||
@@ -1496,6 +1536,27 @@ private fun Bubble(
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
// 🔴 **A sticker has no bubble.** The kit draws none, and a glyph
|
||||
// inside a grey rounded rectangle reads as a broken image rather than
|
||||
// as a sticker. It keeps the timestamp above it — that is the line that
|
||||
// says who and when — and nothing else.
|
||||
val stk = line.sticker
|
||||
if (stk != null) {
|
||||
val context = LocalContext.current
|
||||
Text(
|
||||
StickerPack.glyph(context, stk.pack, stk.id),
|
||||
fontSize = 64.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = Kit.bubblePadding)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
StickerPack.name(context, stk.pack, stk.id) + " sticker"
|
||||
}
|
||||
.testTag("sticker-${stk.id}"),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
// 🔴 **One corner is square, and it points at its author.**
|
||||
|
||||
@@ -18,8 +18,10 @@ import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Mic
|
||||
import androidx.compose.material.icons.outlined.MicOff
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.StickyNote2
|
||||
import androidx.compose.material.icons.outlined.NotificationsNone
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.outlined.PersonOutline
|
||||
@@ -73,6 +75,8 @@ object KitIcon {
|
||||
val Camera: ImageVector = Icons.Outlined.PhotoCamera
|
||||
val Send: ImageVector = Icons.AutoMirrored.Outlined.Send
|
||||
val Mic: ImageVector = Icons.Outlined.Mic
|
||||
val Emoji: ImageVector = Icons.Outlined.EmojiEmotions
|
||||
val Sticker: ImageVector = Icons.Outlined.StickyNote2
|
||||
|
||||
// Calls
|
||||
val Call: ImageVector = Icons.Outlined.Call
|
||||
|
||||
@@ -1287,6 +1287,36 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sticker into whatever this conversation is.
|
||||
*
|
||||
* ⚠️ Forks on group vs peer exactly as sendMessage does. A sticker sent to
|
||||
* a group has to be filed under the group thread, or it scatters across as
|
||||
* many threads as the group has members.
|
||||
*/
|
||||
fun sendSticker(chat: Phase.Chat, stickerId: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val group = chat.group
|
||||
if (group != null) {
|
||||
current.sendGroupSticker(group, StickerPack.CORE, stickerId)
|
||||
} else {
|
||||
current.sendSticker(chat.peerId ?: return@runCatching null,
|
||||
StickerPack.CORE, stickerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
done.onSuccess { sent ->
|
||||
update {
|
||||
it.copy(
|
||||
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
|
||||
error = sent?.let(::describeDelivery),
|
||||
)
|
||||
}
|
||||
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Stickers, from the shipped pack.
|
||||
*
|
||||
* ⚠️ Tapping one SENDS it. A sticker is a whole message — there is nothing to
|
||||
* add it to and nothing further to say — which is the opposite of the emoji
|
||||
* panel below, and the reason these are two controls rather than one.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val stickers = StickerPack.all(context)
|
||||
|
||||
if (stickers.isEmpty()) {
|
||||
// The manifest failed to load. Say so rather than showing an empty
|
||||
// panel that reads as "there are no stickers".
|
||||
Text(
|
||||
"Stickers could not be loaded on this device.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.padding(Kit.gutter),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 64.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.testTag("sticker-picker"),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(Space.sm),
|
||||
) {
|
||||
items(stickers, key = { it.id }) { sticker ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(Space.xs)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(sticker) }
|
||||
.semantics { contentDescription = sticker.name }
|
||||
.testTag("sticker-pick-${sticker.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(sticker.glyph, fontSize = 34.sp, modifier = Modifier.padding(Space.sm))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji, straight into the draft.
|
||||
*
|
||||
* 🔴 **Not a protocol feature.** An emoji is a character: it goes into the text
|
||||
* of an ordinary message and is indistinguishable from any other character once
|
||||
* sent. Nothing on the receiving end needs to understand it, which is exactly
|
||||
* what makes it different from a sticker.
|
||||
*
|
||||
* ⚠️ The panel stays open on tap. Somebody picking three in a row expects three
|
||||
* characters in the box, and closing on each tap would make that impossible.
|
||||
*
|
||||
* ⚠️ A short curated list rather than every emoji Unicode defines. A full
|
||||
* picker is a search field, skin-tone variants, category tabs and a recents
|
||||
* list — a real feature, and one the system keyboard already ships. This is the
|
||||
* common set within reach of a thumb; the keyboard remains the way to reach the
|
||||
* rest.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 44.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(Radius.large))
|
||||
.background(Palette.SurfaceAlt)
|
||||
.testTag("emoji-picker"),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(Space.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items(EMOJI, key = { it }) { emoji ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onPick(emoji) }
|
||||
.testTag("emoji-pick-$emoji"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(emoji, fontSize = 24.sp, modifier = Modifier.padding(Space.sm))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The common set, in the order people actually reach for them. */
|
||||
private val EMOJI = listOf(
|
||||
"😀", "😂", "🥹", "😊", "😍", "😘", "😎", "🤔", "😐", "🙄",
|
||||
"😴", "😭", "😡", "🥳", "🤝", "👍", "👎", "👌", "🙏", "👏",
|
||||
"💪", "👋", "🫡", "🤷", "❤️", "🧡", "💚", "💙", "💜", "🖤",
|
||||
"🔥", "⭐", "✨", "🎉", "🎂", "☕", "🍕", "🍺", "🚀", "✅",
|
||||
"❌", "⚠️", "💤", "👀", "🐛", "📎", "📷", "🎤", "📞", "💬",
|
||||
)
|
||||
@@ -39,6 +39,8 @@ class Session(
|
||||
val mine: Boolean,
|
||||
val at: Long,
|
||||
val attachment: Attachment.Ref? = null,
|
||||
/** Set iff this line is a sticker rather than words. */
|
||||
val sticker: Sticker? = null,
|
||||
/**
|
||||
* What happened to this message on its way out.
|
||||
*
|
||||
@@ -67,6 +69,9 @@ class Session(
|
||||
/** Where an outbound message got to. */
|
||||
enum class Delivery { PENDING, DELIVERED, FAILED }
|
||||
|
||||
/** Which sticker a line is, when it is one. */
|
||||
data class Sticker(val pack: String, val id: String)
|
||||
|
||||
|
||||
|
||||
fun history(threadId: String): List<Line> {
|
||||
@@ -83,6 +88,9 @@ class Session(
|
||||
// ⚠️ `if (has)` rather than `optBoolean`, which would turn a
|
||||
// message stored before this field into a claim of failure and
|
||||
// decorate every older bubble with a warning.
|
||||
sticker = m.optJSONObject("stk")?.let {
|
||||
Sticker(it.optString("pack"), it.optString("id"))
|
||||
},
|
||||
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
|
||||
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
|
||||
)
|
||||
@@ -94,6 +102,7 @@ class Session(
|
||||
text: String,
|
||||
mine: Boolean,
|
||||
attachment: Attachment.Ref? = null,
|
||||
sticker: Sticker? = null,
|
||||
author: Long = 0,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
delivery: Delivery? = null,
|
||||
@@ -102,6 +111,11 @@ class Session(
|
||||
.put("author", author)
|
||||
.put("text", text).put("mine", mine).put("at", at)
|
||||
.also { if (delivery != null) it.put("delivery", delivery.name) }
|
||||
.also {
|
||||
if (sticker != null) {
|
||||
it.put("stk", JSONObject().put("pack", sticker.pack).put("id", sticker.id))
|
||||
}
|
||||
}
|
||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
|
||||
}
|
||||
|
||||
@@ -212,6 +226,42 @@ class Session(
|
||||
vault.saveCallLog(log)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sticker.
|
||||
*
|
||||
* ⚠️ Recorded with its outcome like every other send — a sticker nobody
|
||||
* received must not look like one that was. It does NOT go into the outbox:
|
||||
* see the comment on the record below.
|
||||
*/
|
||||
fun sendSticker(peerUserId: Long, pack: String, stickerId: String):
|
||||
Conversations.Sent {
|
||||
val sent = conversations.sendSticker(peerUserId, pack, stickerId)
|
||||
record(Vault.threadForUser(peerUserId), "", mine = true,
|
||||
sticker = Sticker(pack, stickerId),
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
return sent
|
||||
}
|
||||
|
||||
/** A sticker to a group. */
|
||||
fun sendGroupSticker(group: RelayClient.Group, pack: String, stickerId: String):
|
||||
Conversations.Sent {
|
||||
var delivered = 0
|
||||
var failed = 0
|
||||
for (member in group.memberIds) {
|
||||
if (member == userId) continue
|
||||
val one = conversations.sendSticker(member, pack, stickerId, groupId = group.id)
|
||||
delivered += one.delivered.size
|
||||
failed += one.failed.size
|
||||
}
|
||||
record(Vault.threadForGroup(group.id), "", mine = true, author = userId,
|
||||
sticker = Sticker(pack, stickerId),
|
||||
delivery = if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING)
|
||||
return Conversations.Sent(
|
||||
delivered = List(delivered) { "" },
|
||||
failed = if (failed > 0) mapOf("group" to "some members unreachable") else emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Outbox
|
||||
|
||||
/**
|
||||
@@ -383,7 +433,9 @@ class Session(
|
||||
?.let(Vault::threadForGroup)
|
||||
?: Vault.threadForUser(message.fromUser)
|
||||
record(thread, message.content.text, mine = false,
|
||||
attachment = message.content.attachment, author = message.fromUser)
|
||||
attachment = message.content.attachment,
|
||||
sticker = message.content.sticker?.let { Sticker(it.pack, it.id) },
|
||||
author = message.fromUser)
|
||||
touched += thread
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The shipped sticker set, read from the same manifest the iOS app reads.
|
||||
*
|
||||
* 🔴 **One manifest, both clients.** `Resources/stickers/flamenet.core.json` is
|
||||
* copied into Android's assets; neither side hard-codes the list. The only
|
||||
* protocol bug this project has actually shipped was a client and a server
|
||||
* disagreeing about a field name with nothing to check it against, and a
|
||||
* sticker set defined twice is the same shape of mistake waiting to happen.
|
||||
*
|
||||
* ⚠️ **An unknown id resolves to the fallback, never to nothing.** A pack the
|
||||
* receiver does not have — an older build, a pack added later — must still draw
|
||||
* something. A sticker that renders as an empty bubble is worse than one that
|
||||
* renders as the wrong face.
|
||||
*/
|
||||
object StickerPack {
|
||||
|
||||
data class Sticker(val id: String, val glyph: String, val name: String)
|
||||
|
||||
const val CORE = "flamenet.core"
|
||||
|
||||
private var loaded: Map<String, Sticker>? = null
|
||||
private var order: List<Sticker> = emptyList()
|
||||
private var fallbackGlyph = "🙂"
|
||||
|
||||
/** Every sticker in the shipped pack, in manifest order. */
|
||||
fun all(context: Context): List<Sticker> {
|
||||
ensure(context)
|
||||
return order
|
||||
}
|
||||
|
||||
/**
|
||||
* What to draw for a reference.
|
||||
*
|
||||
* Returns the fallback for anything unrecognised — a different pack, an id
|
||||
* from a newer build — because the alternative is a blank bubble.
|
||||
*/
|
||||
fun glyph(context: Context, pack: String, id: String): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return fallbackGlyph
|
||||
return loaded?.get(id)?.glyph ?: fallbackGlyph
|
||||
}
|
||||
|
||||
fun name(context: Context, pack: String, id: String): String {
|
||||
ensure(context)
|
||||
if (pack != CORE) return "Sticker"
|
||||
return loaded?.get(id)?.name ?: "Sticker"
|
||||
}
|
||||
|
||||
private fun ensure(context: Context) {
|
||||
if (loaded != null) return
|
||||
// ⚠️ Failure is empty, not a crash. A missing or malformed asset should
|
||||
// cost the picker, not the app — and the fallback above keeps every
|
||||
// sticker somebody has already been sent renderable regardless.
|
||||
val text = runCatching {
|
||||
context.assets.open("stickers/$CORE.json").bufferedReader().use { it.readText() }
|
||||
}.getOrNull() ?: run { loaded = emptyMap(); return }
|
||||
|
||||
val json = runCatching { JSONObject(text) }.getOrNull()
|
||||
?: run { loaded = emptyMap(); return }
|
||||
|
||||
fallbackGlyph = json.optString("fallback").ifBlank { "🙂" }
|
||||
val array = json.optJSONArray("stickers")
|
||||
val list = mutableListOf<Sticker>()
|
||||
for (i in 0 until (array?.length() ?: 0)) {
|
||||
val o = array!!.optJSONObject(i) ?: continue
|
||||
val id = o.optString("id")
|
||||
if (id.isBlank()) continue
|
||||
list += Sticker(
|
||||
id = id,
|
||||
glyph = o.optString("glyph").ifBlank { fallbackGlyph },
|
||||
name = o.optString("name").ifBlank { id },
|
||||
)
|
||||
}
|
||||
order = list
|
||||
loaded = list.associateBy { it.id }
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,10 @@ class ScreenshotTest {
|
||||
// out, so no device took it. It waits in the outbox and says so.
|
||||
Session.Line("Are you around later?", mine = true, at = 1_756_300_440_000,
|
||||
delivery = Session.Delivery.PENDING),
|
||||
// A sticker draws bare — no bubble — so this catches the case where
|
||||
// it falls through to the text path and renders as an empty one.
|
||||
Session.Line("", mine = false, at = 1_756_300_500_000,
|
||||
sticker = Session.Sticker("flamenet.core", "wave")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user