feat(chat): constant-width two-check delivery marker that lights up green

Delivery/read markers previously swapped glyphs as acknowledgements
arrived, reflowing the text around them. Both checks now render from
the start in a disabled grey and simply recolour as the state advances
(delivered lights the first check, read lights both), so nothing ever
pushes message text around. Read receipts use the app's primary green
instead of the blue accent, a quick colour tween lights the checks up,
and the standalone marker adds a snappy scale pop when the state
advances. Applies to both matrix and bubbles modes.
This commit is contained in:
callebtc 2026-08-01 16:06:37 +02:00
parent af91abab01
commit 7247d0ad5b
20 changed files with 106 additions and 177 deletions

View File

@ -194,28 +194,38 @@ private fun appendMutedTimestamp(
}
/**
* A delivery-status glyph rendered inline, trailing the timestamp inside a bubble.
* Per-check colours for the inline delivery marker trailing the timestamp inside a bubble.
*
* Mirrors the mapping used by the standalone delivery marker so the two never disagree.
* Both checks always render grey until an acknowledgement turns them on so a status change
* recolours in place and can never reflow the message text.
*/
data class MessageStatusGlyph(
val text: String,
val color: Color,
val bold: Boolean,
val firstColor: Color,
val secondColor: Color,
)
private fun appendStatusGlyph(
builder: AnnotatedString.Builder,
glyph: MessageStatusGlyph,
) {
builder.append(" ")
builder.pushStyle(
SpanStyle(
color = glyph.color,
color = glyph.firstColor,
fontSize = ChatVisualTokens.SystemTimeFontSize,
fontWeight = if (glyph.bold) FontWeight.Bold else FontWeight.Normal,
fontWeight = FontWeight.Normal,
)
)
builder.append(" ${glyph.text}")
builder.append("")
builder.pop()
builder.pushStyle(
SpanStyle(
color = glyph.secondColor,
fontSize = ChatVisualTokens.SystemTimeFontSize,
fontWeight = FontWeight.Normal,
)
)
builder.append("")
builder.pop()
}

View File

@ -8,7 +8,7 @@ import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
@ -16,9 +16,6 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -763,25 +760,26 @@ internal fun TextMessageLayout(
onMessageLongPress?.invoke(message)
}
// Bubble mode pulls the delivery marker into the bubble, trailing the timestamp, so the
// whole message reads as one unit. Same glyph mapping as the standalone marker.
val statusGlyph = if (bubbles && isSelf && message.isPrivate) {
message.deliveryStatus?.let { status ->
when (status) {
is DeliveryStatus.Sending ->
MessageStatusGlyph(stringResource(R.string.status_sending), colorScheme.primary.copy(alpha = 0.6f), bold = false)
is DeliveryStatus.Sent ->
MessageStatusGlyph(stringResource(R.string.status_pending), colorScheme.primary.copy(alpha = 0.6f), bold = false)
is DeliveryStatus.Delivered ->
MessageStatusGlyph(stringResource(R.string.status_sent), colorScheme.primary.copy(alpha = 0.8f), bold = false)
is DeliveryStatus.Read ->
MessageStatusGlyph(stringResource(R.string.status_delivered), colorScheme.secondary, bold = true)
is DeliveryStatus.Failed ->
MessageStatusGlyph(stringResource(R.string.status_failed), colorScheme.error, bold = false)
is DeliveryStatus.PartiallyDelivered ->
MessageStatusGlyph(stringResource(R.string.status_sent), colorScheme.primary.copy(alpha = 0.6f), bold = false)
}
}
// Bubble mode pulls the delivery marker into the bubble, trailing the timestamp. Both
// checks render from the start — grey until an acknowledgement turns them green — so a
// status change recolours in place and never reflows the text. The colour transition is
// animated, which reads as the checks lighting up rather than popping in.
val checkTargets = deliveryCheckColors(
status = if (bubbles && isSelf && message.isPrivate) message.deliveryStatus else null,
colorScheme = colorScheme,
)
val firstCheck by animateColorAsState(
targetValue = checkTargets.first,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "firstCheckColor",
)
val secondCheck by animateColorAsState(
targetValue = checkTargets.second,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "secondCheckColor",
)
val statusGlyph = if (bubbles && isSelf && message.isPrivate && message.deliveryStatus != null) {
MessageStatusGlyph(firstColor = firstCheck, secondColor = secondCheck)
} else {
null
}
@ -1145,43 +1143,77 @@ private fun redeemCashu(context: Context, token: String, preferWallet: Boolean)
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) }
}
/**
* Per-check target colours for the delivery marker.
*
* Both checks always render grey (disabled) until an acknowledgement turns them on so a
* status change recolours in place and never reflows text around it. Read receipts use the
* app's primary green rather than a separate accent. [status] == null yields the all-grey
* baseline used while a message is still being sent.
*/
private fun deliveryCheckColors(status: DeliveryStatus?, colorScheme: ColorScheme): Pair<Color, Color> {
val grey = colorScheme.onSurface.copy(alpha = 0.35f)
val green = colorScheme.primary
return when (status) {
is DeliveryStatus.Read -> green to green
is DeliveryStatus.Delivered -> green to grey
is DeliveryStatus.PartiallyDelivered -> green to grey
is DeliveryStatus.Failed -> colorScheme.error to colorScheme.error
else -> grey to grey
}
}
/** Acknowledgement progress ordering, used to fire the pop only when the state advances. */
private fun deliveryCheckRank(status: DeliveryStatus): Int = when (status) {
is DeliveryStatus.Read -> 3
is DeliveryStatus.Delivered -> 2
is DeliveryStatus.PartiallyDelivered -> 2
is DeliveryStatus.Failed -> 1
else -> 0
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme
val (firstTarget, secondTarget) = deliveryCheckColors(status, colorScheme)
val first by animateColorAsState(
targetValue = firstTarget,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "firstCheckColor",
)
val second by animateColorAsState(
targetValue = secondTarget,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "secondCheckColor",
)
// Status advances on its own as acks come back, so a hard glyph swap reads as a flicker.
// Keyed on the status *type* rather than the instance, because Delivered/Read carry a
// timestamp that would otherwise retrigger the transition on every identical update.
AnimatedContent(
targetState = status::class,
transitionSpec = {
fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith
fadeOut(tween(BitchatMotion.QUICK_MS))
},
label = "deliveryStatus"
) { statusClass ->
val (text, color, weight) = when (statusClass) {
DeliveryStatus.Sending::class ->
Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
// Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity).
DeliveryStatus.Sent::class ->
Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
DeliveryStatus.Delivered::class ->
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal)
DeliveryStatus.Read::class ->
Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold)
DeliveryStatus.Failed::class ->
Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal)
// A single subdued check, without the numeric label.
else ->
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
// Snappy micro pop when the state advances to (more) acknowledged. Keyed on the rank, not
// the instance, because Delivered/Read carry timestamps that would retrigger it otherwise.
val scale = remember { Animatable(1f) }
LaunchedEffect(deliveryCheckRank(status)) {
if (deliveryCheckRank(status) >= 2) {
scale.snapTo(1.3f)
scale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 900f))
}
Text(
text = stringResource(text),
fontSize = 10.sp,
color = color,
fontWeight = weight
)
}
val text = remember(first, second) {
androidx.compose.ui.text.buildAnnotatedString {
pushStyle(androidx.compose.ui.text.SpanStyle(color = first))
append("")
pop()
pushStyle(androidx.compose.ui.text.SpanStyle(color = second))
append("")
pop()
}
}
Text(
text = text,
fontSize = 10.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
)
}

View File

@ -293,12 +293,6 @@
<string name="media_type_file">File</string>
<!-- Mga status ng mensahe -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- Tulong na text sa abiso -->
<string name="notification_sent_image">📷 nagpadala ng larawan</string>

View File

@ -294,12 +294,6 @@
<string name="media_type_file">Fichier</string>
<!-- Statuts de message -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- Aide de texte de notification -->
<string name="notification_sent_image">📷 a envoyé une image</string>

View File

@ -349,12 +349,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">תמונה</string>
<string name="media_type_file">קובץ</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 שלח/ה תמונה</string>
<string name="notification_sent_voice">🎤 שלח/ה הודעה קולית</string>
<string name="notification_sent_file">📎 שלח/ה קובץ</string>

View File

@ -338,12 +338,6 @@
<string name="warning_emoji">⚠️</string>
<!-- Icone stato messaggio -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- Helper file notifiche -->
<string name="notification_file_pdf">📄</string>

View File

@ -302,12 +302,6 @@
<string name="media_type_file">Rakitra</string>
<!-- Famantarana toe-javatra hafatra -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- Mpanampy lahatsoratra fampandrenesana -->
<string name="notification_sent_image">📷 nandefasa sary</string>

View File

@ -374,12 +374,6 @@
<string name="media_type_file">Fail</string>
<!-- Message status icons -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- Notification text helpers -->
<string name="notification_sent_image">📷 menghantar imej</string>

View File

@ -293,12 +293,6 @@
<string name="media_type_file">फाइल</string>
<!-- सन्देश स्थिति -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- सूचना पाठ सहायक -->
<string name="notification_sent_image">📷 तस्वीर पठाइयो</string>

View File

@ -339,12 +339,6 @@
<string name="debug_question_mark">?</string>
<!-- Berichtstatus-pictogrammen -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- Meldingsbestand-emojis -->
<string name="notification_file_pdf">📄</string>

View File

@ -347,12 +347,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">Obraz</string>
<string name="media_type_file">Plik</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 wysłał(a) obraz</string>
<string name="notification_sent_voice">🎤 wysłał(a) wiadomość głosową</string>
<string name="notification_sent_file">📎 wysłał(a) plik</string>

View File

@ -276,13 +276,6 @@
<string name="media_type_image">Изображение</string>
<string name="media_type_file">Файл</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 отправил изображение</string>
<string name="notification_sent_voice">🎤 отправил голосовое</string>
<string name="notification_sent_file">📎 отправил файл</string>

View File

@ -276,13 +276,6 @@
<string name="media_type_image">Bild</string>
<string name="media_type_file">Fil</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 skickade en bild</string>
<string name="notification_sent_voice">🎤 skickade ett röstmeddelande</string>
<string name="notification_sent_file">📎 skickade en fil</string>

View File

@ -352,12 +352,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">படம்</string>
<string name="media_type_file">கோப்பு</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 ஒரு படத்தை அனுப்பியது</string>
<string name="notification_sent_voice">🎤 ஒரு குரல் செய்தியை அனுப்பியது</string>
<string name="notification_sent_file">📎 ஒரு கோப்பை அனுப்பியது</string>

View File

@ -276,13 +276,6 @@
<string name="media_type_image">Görsel</string>
<string name="media_type_file">Dosya</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 görsel gönderdi</string>
<string name="notification_sent_voice">🎤 sesli mesaj gönderdi</string>
<string name="notification_sent_file">📎 dosya gönderdi</string>

View File

@ -341,12 +341,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">Зображення</string>
<string name="media_type_file">Файл</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 надіслав(-ла) зображення</string>
<string name="notification_sent_voice">🎤 надіслав(-ла) голосове повідомлення</string>
<string name="notification_sent_file">📎 надіслав(-ла) файл</string>

View File

@ -347,12 +347,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">图片</string>
<string name="media_type_file">文件</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 发送了一张图片</string>
<string name="notification_sent_voice">🎤 发送了一条语音消息</string>
<string name="notification_sent_file">📎 发送了一个文件</string>

View File

@ -347,12 +347,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">圖片</string>
<string name="media_type_file">檔案</string>
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<string name="notification_sent_image">📷 傳送了一張圖片</string>
<string name="notification_sent_voice">🎤 傳送了一則語音訊息</string>
<string name="notification_sent_file">📎 傳送了一個檔案</string>

View File

@ -293,12 +293,6 @@
<string name="media_type_file">文件</string>
<!-- 消息状态 -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- 通知文本辅助 -->
<string name="notification_sent_image">📷 发送了图片</string>

View File

@ -559,14 +559,6 @@
<string name="media_type_image">Image</string>
<string name="media_type_file">File</string>
<!-- Message status icons -->
<string name="status_sending"></string>
<string name="status_pending"></string>
<string name="status_sent"></string>
<string name="status_delivered">✓✓</string>
<string name="status_failed"></string>
<string name="status_read"></string>
<!-- Notification text helpers -->
<string name="notification_sent_image">📷 sent an image</string>
<string name="notification_sent_voice">🎤 sent a voice message</string>