mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
* first pass * pass 2 * cleanup * capitalization * strings * input bar fixes * fixes * notes * nice * nicer * lists * cleanup * button * fixes * animations * Fix layout jumpiness in chat and geohash people list Three separate causes of things moving when they should not: - Chat lurched whenever a bottom sheet closed. Placement animation is meant to soften insertions and removals, but any relayout moves every item -- a sheet's text field opening the keyboard changes the chat's IME inset, and closing it changes it back. Placement animation is now armed only briefly around a real change to the message list, so items otherwise track the viewport exactly. - Anon list changed height as participants churned. Rows sized to their content, so any reorder could change the card's height; and the card sized to the live anon count, which moves constantly in a busy geohash. Rows now have an exact height, and a trimmed anon card reserves the full capped height regardless of how many are present beyond the cap. - Anons are now their own trailing section rather than a tail on each of "on location" and "teleported in", which had pushed the few recognisable names out of view twice over. Self is never grouped as an anon. Adds 7 tests covering the sectioning and the fixed-length behaviour. * Group geohash people as People and Anon Replaces the "on location" / "teleported in" / "anonymous" split with two sections: peers who announced a nickname, then the anons. Teleport state was never worth a section of its own -- every row already carries it as a distinct glyph -- and splitting on it fragmented the short list people actually read, in a channel where most participants are anonymous anyway. Self stays in the People section even when unnamed. * Key message list state per conversation Switching channels reused every piece of state in MessagesList, because none of it was keyed on which conversation was being shown: - The LazyListState carried the previous channel's scroll offset, so the new channel opened at a stale position and then corrected itself. - hasScrolledToInitialPosition and followIncomingMessages carried over, so a channel entered after scrolling up in another one did not land on its newest message at all. - The arrival tracker had never seen the incoming channel's ids, so a backlog of six or fewer messages was treated as six simultaneous arrivals and each one slid in. - previousMessageCount carried over, arming placement animation for the relayout that the switch itself caused. All of it is now keyed on a conversationKey derived the same way displayMessages is. The tracker also detects a list sharing no ids with the previous one and adopts it silently, which covers /clear and any caller that does not supply a distinct key. Adds 4 tests for wholesale replacement, including the case that the burst cap cannot catch on its own. * fix location channel layout * icon * location sheet * move location error * fix location channel lifecycle bug * remove empty lable * geist mono * timestamp no seconds * new icons * icons * cleanup * mentions * fix mentions * grouping of geohash channel list * colors * fix mention colors * Bring private and group chat headers up to the main header's layout Both conversation headers were built on TopAppBar with a centred title, a back arrow on the left and everything else crowded into the title slot, at 14sp with 14dp icons. Moving between the timeline and a conversation visibly shifted the bar's height, insets and type. Introduces ConversationHeader, built from the main header's own tokens rather than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph in a 44dp slot so it lands exactly where the brand mark does, same -6dp optical nudge pulling the title toward it, same 17sp label. - Drops the back button; the close action on the right is the way out. Leaving a channel outright already lives on its row in the network sheet, so it does not need a second home beside the exit. - Leading glyph is the transport: globe over the internet, wifi/bluetooth/ routed on the mesh, matching the main header's channel button. - Actions are right-aligned and unweighted -- favourite, encryption state, close -- so a long title yields space to them instead of pushing them off screen. - Private chat titles use the primary green like every other header label, rather than orange for Nostr-reachable peers. Height and edge insets now belong to each header variant instead of the ChatFloatingHeader wrapper, which was applying them a second time to the channel header. Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth, wifi, routed, close, check, warning, sync, lock_open, envelope -- so the headers and peer rows no longer mix Material glyphs into the set. * color
799 lines
34 KiB
Kotlin
799 lines
34 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.Close
|
|
import androidx.compose.animation.AnimatedContent
|
|
import androidx.compose.animation.core.AnimationSpec
|
|
import androidx.compose.animation.core.Animatable
|
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
|
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.clickable
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.calculateEndPadding
|
|
import androidx.compose.foundation.layout.calculateStartPadding
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
import androidx.compose.foundation.lazy.itemsIndexed
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.material3.ColorScheme
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.IntOffset
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import com.bitchat.android.ui.theme.BitchatFontFamily
|
|
import com.bitchat.android.R
|
|
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
|
import com.bitchat.android.mesh.MeshService
|
|
import com.bitchat.android.model.BitchatMessage
|
|
import com.bitchat.android.model.BitchatMessageType
|
|
import com.bitchat.android.model.DeliveryStatus
|
|
import com.bitchat.android.ui.media.FileMessageItem
|
|
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
|
import com.bitchat.android.ui.theme.BitchatMotion
|
|
import com.bitchat.android.ui.theme.ChatVisualTokens
|
|
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
|
import com.bitchat.android.ui.theme.MessageBodyTextStyle
|
|
import com.bitchat.android.ui.theme.MessageSenderTextStyle
|
|
import kotlinx.coroutines.delay
|
|
import java.text.SimpleDateFormat
|
|
import java.util.Locale
|
|
|
|
|
|
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
|
|
|
|
/**
|
|
* Message display components for ChatScreen
|
|
* Extracted from ChatScreen.kt for better organization
|
|
*/
|
|
|
|
/** How far a newly arrived message travels up into place. */
|
|
private val MessageEntrySlide = 14.dp
|
|
|
|
/**
|
|
* Entry motion for a new message: quick, with just enough damping to settle rather than snap.
|
|
* Runs entirely on a graphics layer, so it costs a transform and nothing else.
|
|
*/
|
|
private val MessageEntrySpec: AnimationSpec<Float> =
|
|
spring(dampingRatio = 0.85f, stiffness = 1200f)
|
|
|
|
/**
|
|
* Motion for messages being pushed out of the way by an arrival. Softer than the entry so the
|
|
* conversation glides up while the new message itself lands crisply.
|
|
*/
|
|
private val MessagePlacementSpec: FiniteAnimationSpec<IntOffset> = spring(
|
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
stiffness = Spring.StiffnessMediumLow,
|
|
visibilityThreshold = IntOffset.VisibilityThreshold
|
|
)
|
|
|
|
/** Removals are not worth dwelling on. */
|
|
private val MessageFadeOutSpec: FiniteAnimationSpec<Float> = tween(BitchatMotion.QUICK_MS)
|
|
|
|
/**
|
|
* How long placement animation stays armed after the list gains or loses a message.
|
|
*
|
|
* Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut
|
|
* short.
|
|
*/
|
|
private const val PlacementArmWindowMs = 600L
|
|
|
|
/**
|
|
* Above this many simultaneous arrivals, entry animations are skipped.
|
|
*
|
|
* A history sync or a channel switch can append hundreds of messages in one frame. Animating each
|
|
* would spend the entire frame budget on motion nobody asked to see, so a burst is adopted
|
|
* silently and only conversational-pace arrivals animate.
|
|
*/
|
|
internal const val MaxAnimatedArrivals = 6
|
|
|
|
/**
|
|
* Remembers which message ids have already been seen, so genuine arrivals can be told apart from
|
|
* items merely scrolling back into view.
|
|
*
|
|
* This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items
|
|
* on demand, so animating on first composition would replay the animation for every old message
|
|
* the user scrolled back to.
|
|
*/
|
|
internal class MessageArrivalTracker {
|
|
val known = HashSet<String>()
|
|
var seeded = false
|
|
}
|
|
|
|
/**
|
|
* Ids that should animate in on this composition pass.
|
|
*
|
|
* Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after*
|
|
* the frame's composition, by which point a new message's item has already composed and would
|
|
* have missed its cue.
|
|
*/
|
|
internal fun MessageArrivalTracker.arrivals(messages: List<BitchatMessage>): Set<String> {
|
|
if (!seeded) {
|
|
// First load adopts everything silently. A whole screenful animating on open reads as a
|
|
// glitch, not a flourish.
|
|
messages.forEach { known.add(it.id) }
|
|
seeded = true
|
|
return emptySet()
|
|
}
|
|
|
|
// A list with nothing in common with the last one is a different conversation, not a burst of
|
|
// arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it
|
|
// silently rather than sliding in every message at once.
|
|
val isWholesaleReplacement =
|
|
messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known }
|
|
|
|
// `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass.
|
|
val added = messages.filter { known.add(it.id) }
|
|
|
|
if (known.size > messages.size) {
|
|
// Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot
|
|
// grow without bound and so re-added messages animate again.
|
|
known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id })
|
|
}
|
|
|
|
return when {
|
|
isWholesaleReplacement -> emptySet()
|
|
added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet()
|
|
else -> added.mapTo(HashSet(added.size)) { it.id }
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun MessagesList(
|
|
messages: List<BitchatMessage>,
|
|
currentUserNickname: String,
|
|
meshService: MeshService,
|
|
modifier: Modifier = Modifier,
|
|
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
|
|
/**
|
|
* Extra inset on top of the list's own gutters.
|
|
*
|
|
* The chat screen's bars are translucent and the list scrolls underneath them, so the caller
|
|
* has to reserve room for their heights here rather than by shrinking the viewport.
|
|
*/
|
|
contentPadding: PaddingValues = PaddingValues(0.dp),
|
|
/**
|
|
* Identity of the conversation being shown — a channel, a geohash, a peer.
|
|
*
|
|
* Everything below that is per-conversation state is keyed on this. Without it, switching
|
|
* channels reused the previous conversation's scroll offset, follow flag and seen-message set,
|
|
* so the new channel opened at a stale position and then animated itself into place.
|
|
*/
|
|
conversationKey: Any? = null,
|
|
forceScrollToBottom: Boolean = false,
|
|
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
|
onNicknameClick: ((String) -> Unit)? = null,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
|
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
|
onImageClick: ((String, List<String>, Int) -> Unit)? = null
|
|
) {
|
|
val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) {
|
|
mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
|
|
}
|
|
|
|
// A fresh scroll position per conversation. Sharing one state meant a switch inherited the
|
|
// previous channel's offset and then had to correct itself, which is what the jump was.
|
|
//
|
|
// Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved
|
|
// offset on every switch, so a conversation always opens on its newest message instead of
|
|
// wherever the reader happened to be some time ago, with unseen messages below them.
|
|
val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) {
|
|
LazyListState()
|
|
}
|
|
|
|
// Track if this is the first time messages are being loaded
|
|
var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) }
|
|
var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) }
|
|
|
|
// Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away
|
|
LaunchedEffect(messages.size) {
|
|
if (messages.isNotEmpty()) {
|
|
val isFirstLoad = !hasScrolledToInitialPosition
|
|
if (isFirstLoad || followIncomingMessages) {
|
|
listState.scrollToItem(0)
|
|
if (isFirstLoad) {
|
|
hasScrolledToInitialPosition = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track whether user has scrolled away from the latest messages
|
|
val isAtLatest by remember(listState) {
|
|
derivedStateOf {
|
|
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
|
|
firstVisibleIndex <= 2
|
|
}
|
|
}
|
|
LaunchedEffect(isAtLatest) {
|
|
followIncomingMessages = isAtLatest
|
|
onScrolledUpChanged?.invoke(!isAtLatest)
|
|
}
|
|
|
|
// Force scroll to bottom when requested (e.g., when user sends a message)
|
|
LaunchedEffect(forceScrollToBottom) {
|
|
if (messages.isNotEmpty()) {
|
|
// With reverseLayout=true and reversed data, latest is at index 0
|
|
followIncomingMessages = true
|
|
listState.scrollToItem(0)
|
|
}
|
|
}
|
|
|
|
// Recomputed only when the list actually gains or loses a message, and synchronously, so the
|
|
// arriving item can read its cue during the same composition pass in which it first appears.
|
|
// Reset per conversation, so a switch adopts the incoming messages silently instead of
|
|
// treating a whole channel's backlog as brand-new arrivals and sliding each one in.
|
|
val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() }
|
|
val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) {
|
|
arrivalTracker.arrivals(messages)
|
|
}
|
|
|
|
// Placement animation exists to soften insertions and removals. But *any* relayout moves every
|
|
// item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer
|
|
// growing a line — and animating those made the whole conversation lurch. So it is armed only
|
|
// briefly around a genuine change to the list, and is otherwise off, letting items track the
|
|
// viewport exactly.
|
|
var placementArmed by remember(conversationKey) { mutableStateOf(false) }
|
|
var previousMessageCount by remember(conversationKey) { mutableStateOf<Int?>(null) }
|
|
LaunchedEffect(conversationKey, messages.size) {
|
|
val previous = previousMessageCount
|
|
previousMessageCount = messages.size
|
|
// Skip the first composition: the list settling into its initial padding is not a change
|
|
// worth animating.
|
|
if (previous == null || previous == messages.size) return@LaunchedEffect
|
|
placementArmed = true
|
|
delay(PlacementArmWindowMs)
|
|
placementArmed = false
|
|
}
|
|
|
|
val layoutDirection = LocalLayoutDirection.current
|
|
LazyColumn(
|
|
state = listState,
|
|
// Wider side gutters than the old 12.dp: the redesign trades a little line length for
|
|
// a much calmer edge, and long monospace lines were running into the screen bezel.
|
|
contentPadding = PaddingValues(
|
|
start = 16.dp + contentPadding.calculateStartPadding(layoutDirection),
|
|
end = 16.dp + contentPadding.calculateEndPadding(layoutDirection),
|
|
top = 8.dp + contentPadding.calculateTopPadding(),
|
|
bottom = 12.dp + contentPadding.calculateBottomPadding()
|
|
),
|
|
// Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm;
|
|
// a new speaker gets additional separation from the visible sender row's top inset.
|
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
modifier = modifier,
|
|
reverseLayout = true
|
|
) {
|
|
val reversed = messages.asReversed()
|
|
itemsIndexed(
|
|
items = reversed,
|
|
key = { _, message -> message.id }
|
|
) { reversedIndex, message ->
|
|
// reverseLayout renders index 0 at the bottom, so the chronological predecessor of
|
|
// this row lives at a *higher* original index offset. Resolve against the original
|
|
// list rather than the reversed view to keep the grouping logic readable.
|
|
val originalIndex = messages.lastIndex - reversedIndex
|
|
val previous = messages.getOrNull(originalIndex - 1)
|
|
val isGrouped = MessageGrouping.shouldGroup(previous, message)
|
|
|
|
// Decided once per item instance, so an item recycling back into view during a scroll
|
|
// never re-animates. Items that are not arriving skip the animation machinery
|
|
// entirely: no Animatable, no coroutine, and no extra render layer per row.
|
|
val isArriving = remember(message.id) { message.id in enteringIds }
|
|
val entryModifier = if (isArriving) {
|
|
val entry = remember(message.id) { Animatable(0f) }
|
|
LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) }
|
|
// A draw-time transform only: no measure, no layout, and no recomposition of the
|
|
// message content on any frame of the animation.
|
|
Modifier.graphicsLayer {
|
|
val progress = entry.value
|
|
alpha = progress
|
|
translationY = (1f - progress) * MessageEntrySlide.toPx()
|
|
}
|
|
} else {
|
|
Modifier
|
|
}
|
|
|
|
MessageItem(
|
|
message = message,
|
|
messages = messages,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
mentionPeerIdentities = resolvedMentionPeerIdentities,
|
|
showSender = !isGrouped,
|
|
topSpacing = MessageGrouping.topSpacingFor(
|
|
isGrouped = isGrouped,
|
|
isFirstInList = originalIndex == 0
|
|
),
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
onImageClick = onImageClick,
|
|
modifier = Modifier
|
|
// Animates the shift when a neighbour is inserted or removed: this is what
|
|
// makes the conversation glide up instead of jumping.
|
|
.animateItem(
|
|
// Entry fade is handled by entryModifier, together with the slide, so the
|
|
// two cannot drift out of step.
|
|
fadeInSpec = null,
|
|
placementSpec = if (placementArmed) MessagePlacementSpec else null,
|
|
fadeOutSpec = MessageFadeOutSpec
|
|
)
|
|
.then(entryModifier)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun MessageItem(
|
|
message: BitchatMessage,
|
|
currentUserNickname: String,
|
|
meshService: MeshService,
|
|
messages: List<BitchatMessage> = emptyList(),
|
|
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
|
showSender: Boolean = true,
|
|
topSpacing: Dp = 0.dp,
|
|
onNicknameClick: ((String) -> Unit)? = null,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
|
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
|
onImageClick: ((String, List<String>, Int) -> Unit)? = null,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) }
|
|
|
|
Column(
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.padding(top = topSpacing),
|
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
|
) {
|
|
Box(modifier = Modifier.fillMaxWidth()) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.Start,
|
|
verticalAlignment = Alignment.Top
|
|
) {
|
|
// Provide a small end padding for own private messages so overlay doesn't cover text
|
|
val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
|
|
// Create a custom layout that combines selectable text with clickable nickname areas
|
|
MessageTextWithClickableNicknames(
|
|
message = message,
|
|
messages = messages,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
mentionPeerIdentities = mentionPeerIdentities,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
showSender = showSender,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
onImageClick = onImageClick,
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.padding(end = endPad)
|
|
)
|
|
}
|
|
|
|
// Delivery status for private messages (overlay, non-displacing)
|
|
if (message.isPrivate && message.sender == currentUserNickname) {
|
|
message.deliveryStatus?.let { status ->
|
|
Box(
|
|
modifier = Modifier
|
|
.align(Alignment.TopEnd)
|
|
.padding(top = 2.dp)
|
|
) {
|
|
DeliveryStatusIcon(status = status)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Link previews removed; links are now highlighted inline and clickable within the message text
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
private fun MessageTextWithClickableNicknames(
|
|
message: BitchatMessage,
|
|
messages: List<BitchatMessage>,
|
|
currentUserNickname: String,
|
|
meshService: MeshService,
|
|
mentionPeerIdentities: Map<String, PeerIdentity>,
|
|
colorScheme: ColorScheme,
|
|
timeFormatter: SimpleDateFormat,
|
|
showSender: Boolean,
|
|
onNicknameClick: ((String) -> Unit)?,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
|
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
|
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val palette = LocalBitchatPalette.current
|
|
|
|
// Image special rendering
|
|
if (message.type == BitchatMessageType.Image) {
|
|
com.bitchat.android.ui.media.ImageMessageItem(
|
|
message = message,
|
|
messages = messages,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
showSender = showSender,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
onImageClick = onImageClick,
|
|
modifier = modifier
|
|
)
|
|
return
|
|
}
|
|
|
|
// Voice note special rendering
|
|
if (message.type == BitchatMessageType.Audio) {
|
|
com.bitchat.android.ui.media.AudioMessageItem(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
showSender = showSender,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
modifier = modifier
|
|
)
|
|
return
|
|
}
|
|
|
|
// File special rendering
|
|
if (message.type == BitchatMessageType.File) {
|
|
val path = message.content.trim()
|
|
// Derive sending progress if applicable
|
|
val (overrideProgress, _) = when (val st = message.deliveryStatus) {
|
|
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
|
|
if (st.total > 0 && st.reached < st.total) {
|
|
(st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending
|
|
} else null to null
|
|
}
|
|
else -> null to null
|
|
}
|
|
Column(modifier = modifier.fillMaxWidth()) {
|
|
// Header: nickname + timestamp line above the file, identical styling to text messages
|
|
val headerText = formatMessageHeaderAnnotatedString(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
myPeerID = meshService.myPeerID,
|
|
palette = palette,
|
|
contentColor = colorScheme.onSurface,
|
|
timeFormatter = timeFormatter,
|
|
includeSender = showSender
|
|
)
|
|
val haptic = LocalHapticFeedback.current
|
|
AnnotatedClickableText(
|
|
text = headerText,
|
|
annotationTags = listOf("nickname_click"),
|
|
onAnnotationClick = { tag, item ->
|
|
if (tag == "nickname_click" && onNicknameClick != null) {
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
onNicknameClick.invoke(item)
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
},
|
|
onLongPress = { onMessageLongPress?.invoke(message) },
|
|
fontFamily = BitchatFontFamily,
|
|
color = colorScheme.onSurface,
|
|
)
|
|
|
|
// Try to load the file packet from the path
|
|
val packet = try {
|
|
val file = java.io.File(path)
|
|
if (file.exists()) {
|
|
// Create a temporary BitchatFilePacket for display
|
|
// In a real implementation, this would be stored with the packet metadata
|
|
com.bitchat.android.model.BitchatFilePacket(
|
|
fileName = file.name,
|
|
fileSize = file.length(),
|
|
mimeType = com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name),
|
|
content = file.readBytes()
|
|
)
|
|
} else null
|
|
} catch (e: Exception) {
|
|
null
|
|
}
|
|
|
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
|
Box {
|
|
if (packet != null) {
|
|
if (overrideProgress != null) {
|
|
// Show sending animation while in-flight
|
|
com.bitchat.android.ui.media.FileSendingAnimation(
|
|
fileName = packet.fileName,
|
|
progress = overrideProgress,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
} else {
|
|
// Static file display with open/save dialog
|
|
FileMessageItem(
|
|
packet = packet,
|
|
onFileClick = {
|
|
// handled inside FileMessageItem via dialog
|
|
}
|
|
)
|
|
}
|
|
|
|
// Cancel button overlay during sending
|
|
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is DeliveryStatus.PartiallyDelivered)
|
|
if (showCancel) {
|
|
Box(
|
|
modifier = Modifier
|
|
.align(Alignment.TopEnd)
|
|
.padding(4.dp)
|
|
.size(22.dp)
|
|
.background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape)
|
|
.clickable { onCancelTransfer?.invoke(message) },
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.Close,
|
|
contentDescription = stringResource(R.string.cd_cancel),
|
|
tint = colorScheme.onSurface,
|
|
modifier = Modifier.size(14.dp)
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
Text(
|
|
text = stringResource(R.string.file_unavailable),
|
|
fontFamily = BitchatFontFamily,
|
|
color = palette.textTertiary
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (message.sender == "system") {
|
|
// Background narration: `// Tor started. Routing all chats…`
|
|
val annotatedText = remember(message, colorScheme.onSurface) {
|
|
formatSystemMessage(
|
|
message = message,
|
|
contentColor = colorScheme.onSurface,
|
|
timeFormatter = timeFormatter
|
|
)
|
|
}
|
|
|
|
val haptic = LocalHapticFeedback.current
|
|
Text(
|
|
text = annotatedText,
|
|
modifier = modifier.pointerInput(message) {
|
|
detectTapGestures(
|
|
onLongPress = {
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
onMessageLongPress?.invoke(message)
|
|
}
|
|
)
|
|
},
|
|
fontFamily = BitchatFontFamily,
|
|
softWrap = true,
|
|
overflow = TextOverflow.Visible,
|
|
style = ChatVisualTokens.SystemActionStyle.copy(
|
|
color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha),
|
|
)
|
|
)
|
|
} else {
|
|
TextMessageLayout(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
mentionPeerIdentities = mentionPeerIdentities,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
showSender = showSender,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
modifier = modifier,
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun TextMessageLayout(
|
|
message: BitchatMessage,
|
|
currentUserNickname: String,
|
|
meshService: MeshService,
|
|
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
|
colorScheme: ColorScheme,
|
|
timeFormatter: SimpleDateFormat,
|
|
onNicknameClick: ((String) -> Unit)?,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
|
modifier: Modifier = Modifier,
|
|
showSender: Boolean = true,
|
|
bodyContent: String = message.content,
|
|
) {
|
|
val palette = LocalBitchatPalette.current
|
|
val myPeerId = meshService.myPeerID
|
|
val displayMessage = remember(message, bodyContent) {
|
|
if (bodyContent == message.content) message else message.copy(content = bodyContent)
|
|
}
|
|
val senderText = remember(message, currentUserNickname, myPeerId, palette) {
|
|
formatTextMessageSender(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
myPeerID = myPeerId,
|
|
palette = palette,
|
|
)
|
|
}
|
|
// The timestamp trails the body rather than occupying its own column, so a short message
|
|
// no longer reserves a full-width row for eight grey characters.
|
|
val bodyText = remember(
|
|
displayMessage,
|
|
currentUserNickname,
|
|
palette,
|
|
colorScheme.onSurface,
|
|
colorScheme.secondary,
|
|
mentionPeerIdentities,
|
|
timeFormatter
|
|
) {
|
|
formatTextMessageBody(
|
|
message = displayMessage,
|
|
currentUserNickname = currentUserNickname,
|
|
palette = palette,
|
|
contentColor = colorScheme.onSurface,
|
|
linkColor = colorScheme.secondary,
|
|
mentionPeerIdentities = mentionPeerIdentities,
|
|
timeFormatter = timeFormatter,
|
|
)
|
|
}
|
|
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
|
val haptic = LocalHapticFeedback.current
|
|
val context = LocalContext.current
|
|
val handleLongPress: () -> Unit = {
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
onMessageLongPress?.invoke(message)
|
|
}
|
|
|
|
Column(
|
|
modifier = modifier.fillMaxWidth(),
|
|
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
|
|
) {
|
|
if (showSender) {
|
|
AnnotatedClickableText(
|
|
text = senderText,
|
|
annotationTags = listOf("nickname_click"),
|
|
onAnnotationClick = { tag, item ->
|
|
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
onNicknameClick.invoke(item)
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
},
|
|
onLongPress = handleLongPress,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(top = MessageGrouping.SENDER_TOP_PADDING),
|
|
fontFamily = BitchatFontFamily,
|
|
softWrap = false,
|
|
overflow = TextOverflow.Ellipsis,
|
|
style = MessageSenderTextStyle,
|
|
)
|
|
}
|
|
|
|
AnnotatedClickableText(
|
|
text = bodyText,
|
|
annotationTags = listOf("geohash_click", "url_click"),
|
|
onAnnotationClick = { tag, item ->
|
|
when (tag) {
|
|
"geohash_click" -> {
|
|
navigateToGeohash(context, item)
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
true
|
|
}
|
|
|
|
"url_click" -> {
|
|
openMessageUrl(context, item)
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
true
|
|
}
|
|
|
|
else -> false
|
|
}
|
|
},
|
|
onLongPress = handleLongPress,
|
|
fontFamily = BitchatFontFamily,
|
|
softWrap = true,
|
|
overflow = TextOverflow.Visible,
|
|
style = MessageBodyTextStyle.copy(color = colorScheme.onSurface),
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
// 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)
|
|
}
|
|
|
|
Text(
|
|
text = stringResource(text),
|
|
fontSize = 10.sp,
|
|
color = color,
|
|
fontWeight = weight
|
|
)
|
|
}
|
|
}
|