diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 73b6a1b..d2d45cb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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.** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index af5ee36..8340be4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index f6c74e0..46153f6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt new file mode 100644 index 0000000..5a31edc --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt @@ -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( + "😀", "😂", "🥹", "😊", "😍", "😘", "😎", "🤔", "😐", "🙄", + "😴", "😭", "😡", "🥳", "🤝", "👍", "👎", "👌", "🙏", "👏", + "💪", "👋", "🫡", "🤷", "❤️", "🧡", "💚", "💙", "💜", "🖤", + "🔥", "⭐", "✨", "🎉", "🎂", "☕", "🍕", "🍺", "🚀", "✅", + "❌", "⚠️", "💤", "👀", "🐛", "📎", "📷", "🎤", "📞", "💬", +) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 554901c..759797d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 { @@ -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 } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt new file mode 100644 index 0000000..71d8db8 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt @@ -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? = null + private var order: List = emptyList() + private var fallbackGlyph = "🙂" + + /** Every sticker in the shipped pack, in manifest order. */ + fun all(context: Context): List { + 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() + 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 } + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index d3f906b..f8dd164 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -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")), ), ) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 39e16ea..17fd97e 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -313,6 +313,11 @@ class Conversations( * could read signalling would learn both parties' addresses even on a call * it never carries. */ + /** A sticker to one person, or to a group when `groupId` is set. */ + fun sendSticker(toUserId: Long, pack: String, id: String, + groupId: String? = null, messageId: String? = null): Sent = + sendPlaintext(toUserId, Content.sticker(pack, id, groupId, messageId)) + fun sendCallSignal(toUserId: Long, signal: Content.CallSignal): Sent = sendPlaintext(toUserId, Content.callSignal(signal))