From f27ba9509d14bdd0ba3263d99ac3bc0cfe884656 Mon Sep 17 00:00:00 2001 From: sansen Date: Tue, 25 Aug 2026 13:17:45 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF1.=20=E4=BF=AE=E5=A4=8D=E7=9C=9F?= =?UTF-8?q?=E6=9C=BA=E5=B4=A9=E6=BA=83=EF=BC=9A=E6=B6=88=E6=81=AF=20ID=20?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E5=8C=96=EF=BC=88LazyColumn=20=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=20key=EF=BC=89+=20=E6=B6=88=E6=81=AF=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=BB=9A=E5=8A=A8=E5=85=88=E7=AD=89=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=88layout=20state=20not=20idle=EF=BC=89?= =?UTF-8?q?=202.=20=E6=96=B0=E5=A2=9E=20BBCode=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=EF=BC=9A=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=AC=A2=E8=BF=8E=E6=B6=88?= =?UTF-8?q?=E6=81=AF/=E9=A2=91=E9=81=93=E6=8F=8F=E8=BF=B0=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20b/i/u/s/color/size/url=EF=BC=8C=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E5=8F=AF=E7=82=B9=E5=87=BB=EF=BC=8C=E7=97=85=E6=80=81=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=B8=8D=E5=B4=A9=E6=BA=83=203.=20=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E5=8D=A1=E6=88=90=E5=91=98=E9=95=BF=E6=8C=89=E6=B3=A2=E7=BA=B9?= =?UTF-8?q?=E5=9C=86=E8=A7=92=204.=20=E7=89=88=E6=9C=AC=E5=8F=B7=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E8=87=B3=201.0.10=EF=BC=88versionCode=2010=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 4 +- .../com/tsmobile/app/ui/components/Bbcode.kt | 169 ++++++++++++++++++ .../app/ui/components/ChannelDetailCard.kt | 2 +- .../tsmobile/app/ui/components/MessageList.kt | 24 ++- .../app/ui/components/ServerDetailCard.kt | 2 +- .../tsmobile/app/ui/components/VoiceCard.kt | 2 + .../tsmobile/app/viewmodel/ChatViewModel.kt | 13 +- 7 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 android/app/src/main/java/com/tsmobile/app/ui/components/Bbcode.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2c57b00..7aaf7ca 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.tsmobile.app" minSdk = 26 targetSdk = 35 - versionCode = 9 - versionName = "1.0.9" + versionCode = 10 + versionName = "1.0.10" } signingConfigs { diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/Bbcode.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/Bbcode.kt new file mode 100644 index 0000000..2321e9a --- /dev/null +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/Bbcode.kt @@ -0,0 +1,169 @@ +package com.tsmobile.app.ui.components + +import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * 轻量 BBCode → AnnotatedString 解析器,用于服务器欢迎消息 / 频道描述等富文本。 + * + * 支持标签: + * - [b] [i] [u] [s] [color=#RRGGBB|name] [size=N] [url=http://...][url] + * + * 不支持(原样保留内容、忽略标签):[img] [list] [table] 等。 + * 未知标签会被安全忽略,不会因为畸形 BBCode 崩溃。 + */ +object Bbcode { + + private data class RunStyle( + val bold: Boolean = false, + val italic: Boolean = false, + val underline: Boolean = false, + val strike: Boolean = false, + val color: Color? = null, + val size: TextUnit? = null, + val url: String? = null, + ) + + private val tagRegex = Regex("""\[/?([A-Za-z]+)(?:=([^\]]*))?]""") + + /** 链接默认颜色(Material 蓝) */ + private val linkColor = Color(0xFF1A73E8) + + fun parse(raw: String): AnnotatedString { + return try { + parseInternal(raw) + } catch (e: Exception) { + // BBCode 解析绝不应因畸形输入让 App 崩溃:任何异常都退化为纯文本 + Log.d("Bbcode", "parse failed, raw=${raw.take(120)}", e) + AnnotatedString(raw) + } + } + + private fun parseInternal(raw: String): AnnotatedString { + val builder = AnnotatedString.Builder() + val stack = ArrayDeque() + var current = RunStyle() + + var i = 0 + while (i < raw.length) { + if (raw[i] == '[') { + val m = tagRegex.find(raw, i) + if (m != null && m.range.first == i) { + val isClose = m.value.getOrNull(1) == '/' + // 用 getOrNull 防 Kotlin Regex groupValues 越界(No group N)导致崩溃 + val tag = m.groupValues.getOrNull(1)?.lowercase() ?: "" + val value = m.groupValues.getOrNull(2)?.trim()?.ifBlank { null } + + if (isClose) { + current = stack.removeLastOrNull() ?: current + } else { + // [br] 作为换行处理(无闭合标签) + if (tag == "br") { + builder.append("\n") + i = m.range.last + 1 + continue + } + stack.addLast(current) + current = when (tag) { + "b" -> current.copy(bold = true) + "i" -> current.copy(italic = true) + "u" -> current.copy(underline = true) + "s" -> current.copy(strike = true) + "color" -> current.copy(color = parseColor(value)) + "size" -> current.copy(size = parseSize(value)) + "url" -> current.copy(url = value ?: current.url) + else -> current + } + } + i = m.range.last + 1 + continue + } + } + + // 累积普通文本,直到下一个 '[' + val next = raw.indexOf('[', i) + val text = if (next < 0) raw.substring(i) else raw.substring(i, next) + if (text.isNotEmpty()) { + appendStyled(builder, current, text) + i += text.length + } else { + // 当前字符是 '[' 但不是合法 BBCode 标签(如 [歌名] 等中文占位符): + // 按普通字符保留,避免被吞掉 + appendStyled(builder, current, raw[i].toString()) + i++ + } + } + return builder.toAnnotatedString() + } + + private fun appendStyled(builder: AnnotatedString.Builder, style: RunStyle, text: String) { + val link = style.url + if (link != null) { + // 链接:可点击(Text 通过 LocalUriHandler 打开)+ 下划线 + 链接色 + builder.withLink( + LinkAnnotation.Url( + link, + TextLinkStyles(style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)), + ), + ) { + // 链接内部仍保留 b/i/颜色/字号等基础格式 + pushStyle(styleSpan(style.copy(url = null, color = style.color))) + append(text) + pop() + } + } else { + builder.pushStyle(styleSpan(style)) + builder.append(text) + builder.pop() + } + } + + private fun styleSpan(s: RunStyle): SpanStyle { + val decoration = when { + s.underline -> TextDecoration.Underline + s.strike -> TextDecoration.LineThrough + else -> null + } + return SpanStyle( + fontWeight = if (s.bold) FontWeight.Bold else null, + fontStyle = if (s.italic) FontStyle.Italic else null, + textDecoration = decoration, + color = s.color ?: Color.Unspecified, + fontSize = s.size ?: TextUnit.Unspecified, + ) + } + + private fun parseColor(value: String?): Color? { + if (value.isNullOrBlank()) return null + val v = value.removePrefix("#").removePrefix("0x") + if (v.length == 6) { + return try { Color(("FF" + v).toLong(16)) } catch (_: Exception) { null } + } + if (v.length == 8) { + return try { Color(v.toLong(16)) } catch (_: Exception) { null } + } + return when (value.lowercase()) { + "red" -> Color(0xFFFF0000); "green" -> Color(0xFF00FF00) + "blue" -> Color(0xFF0000FF); "yellow" -> Color(0xFFFFFF00) + "white" -> Color(0xFFFFFFFF); "black" -> Color(0xFF000000) + "orange" -> Color(0xFFFFA500); "purple" -> Color(0xFF800080) + "grey", "gray" -> Color(0xFF808080) + else -> null + } + } + + private fun parseSize(value: String?): TextUnit? { + val n = value?.toFloatOrNull() ?: return null + return n.coerceIn(6f, 40f).sp + } +} diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/ChannelDetailCard.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/ChannelDetailCard.kt index deee09e..a029fa9 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/ChannelDetailCard.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/ChannelDetailCard.kt @@ -63,7 +63,7 @@ private fun ChannelInfoSection(channelDetail: ChannelDetailInfo) { if (channelDetail.description.isNotEmpty()) { Spacer(Modifier.height(UiTokens.Spacing.Small)) Text("描述", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(channelDetail.description, style = MaterialTheme.typography.bodySmall) + Text(Bbcode.parse(channelDetail.description), style = MaterialTheme.typography.bodySmall) } } } diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt index c670ca6..a596c2a 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/MessageList.kt @@ -27,22 +27,30 @@ fun MessageList( val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + // 统一数据源:去重(兜底防重复 key 崩溃),后续都用 messageItems,避免 count 与 items 不一致 + val messageItems = messages.distinctBy { it.id } + // 是否在底部(用于判断是否自动滚动) val isAtBottom by remember { derivedStateOf { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() - lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2 + lastVisibleItem != null && lastVisibleItem.index >= messageItems.size - 2 } } // 新消息到达时自动滚动到底部 - LaunchedEffect(messages.size) { - val count = messages.size + LaunchedEffect(messageItems.size) { + val count = messageItems.size if (count > 0) { + // 先等当前帧(measure/layout)完成再滚动,避免在 LazyColumn measure 期间操作 + // scrollState 触发 "layout state is not idle before measure starts" 崩溃 + withFrameNanos { } try { listState.animateScrollToItem(count - 1) } catch (_: IllegalArgumentException) { // 列表在滚动过程中被清空/缩短(如断线清理会话),忽略即可 + } catch (_: IllegalStateException) { + // scrollState 尚未 idle(measure 竞态/键盘布局变化),忽略本次滚动 } } } @@ -55,7 +63,7 @@ fun MessageList( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( - items = messages, + items = messageItems, key = { it.id } ) { message -> MessageItem(message = message) @@ -63,7 +71,7 @@ fun MessageList( } // 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条 - if (!isAtBottom && messages.isNotEmpty()) { + if (!isAtBottom && messageItems.isNotEmpty()) { Surface( modifier = Modifier .align(Alignment.BottomCenter) @@ -76,13 +84,17 @@ fun MessageList( ) { TextButton( onClick = { - val count = messages.size + val count = messageItems.size if (count > 0) { coroutineScope.launch { + // 等一帧确保布局完成后再滚动 + withFrameNanos { } try { listState.animateScrollToItem(count - 1) } catch (_: IllegalArgumentException) { // 列表在点击瞬间被清空/缩短,忽略即可 + } catch (_: IllegalStateException) { + // scrollState 尚未 idle,忽略本次滚动 } } } diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt index 5051ff5..5fdc8a9 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt @@ -103,7 +103,7 @@ fun ServerDetailCard( if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) { SectionCard(title = "欢迎消息") { Text( - text = serverInfo.welcomeMessage, + text = Bbcode.parse(serverInfo.welcomeMessage), style = MaterialTheme.typography.bodySmall, ) } diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt index 39d30ac..f5051d7 100644 --- a/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt +++ b/android/app/src/main/java/com/tsmobile/app/ui/components/VoiceCard.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -178,6 +179,7 @@ private fun MemberRow( modifier = Modifier .fillMaxWidth() .heightIn(min = 64.dp) + .clip(RoundedCornerShape(UiTokens.Spacing.Medium)) .background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium)) .combinedClickable(onClick = {}, onLongClick = onLongClick) .padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium), diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt index 969d8c9..333bb55 100644 --- a/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt +++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/ChatViewModel.kt @@ -29,8 +29,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private const val TAG = "ChatViewModel" /** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */ private const val DELIVERY_TIMEOUT_MS = 10_000L - /** 系统消息 ID 计数器,避免同毫秒并发调用产生重复 key */ - private val sysMsgSeq = AtomicLong(0) + /** 全局消息 ID 递增序列:保证同一毫秒内生成的消息 ID 唯一, + * 避免 LazyColumn/Row 以 id 为 key 时出现 "Key was already used" 崩溃 */ + private val msgIdSeq = AtomicLong(0) } // 当前会话的消息列表 @@ -94,7 +95,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { */ fun addSystemMessage(content: String) { val message = ChatMessage( - id = "sys_${System.currentTimeMillis()}_${sysMsgSeq.incrementAndGet()}", + id = "sys_${System.currentTimeMillis()}_${msgIdSeq.incrementAndGet()}", targetMode = 2, targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L, senderId = 0, @@ -132,7 +133,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val selfName = Repository.getClient(selfId)?.nickname ?: "" // 生成唯一 ID(用于匹配回显) - val messageId = "local_${System.currentTimeMillis()}_${selfId}" + // 追加全局递增 seq,避免同毫秒多条本地消息 ID 重复 + val messageId = "local_${System.currentTimeMillis()}_${selfId}_${msgIdSeq.incrementAndGet()}" val timestamp = System.currentTimeMillis() // 1. 立即添加到本地列表(PENDING 状态) @@ -280,7 +282,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 非自己的消息,或无法匹配的自己的消息 → 正常归档 val message = ChatMessage( - id = "${System.currentTimeMillis()}_${senderId}", + // 追加全局递增 seq,避免同毫秒同发送者多条消息 ID 重复 + id = "${System.currentTimeMillis()}_${senderId}_${msgIdSeq.incrementAndGet()}", targetMode = targetMode, targetId = targetId, senderId = senderId,