mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
af91abab01
commit
7247d0ad5b
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user