8 Commits
Author SHA1 Message Date
AskaEth 9ff2de3a1d merge: W55 —— 解析器剥掉落单标签(修后端畸形 <p><p>正文</p> 导致标签字面显示在气泡里) 2026-10-06 21:26:34 +08:00
AskaEth f3c1121887 merge: W54D —— 分享确认面板正文预览改成三行窗口 + 内部滚动(发送前能读到整段正文) 2026-10-06 21:26:33 +08:00
AskaEth a861881ec4 merge: W54C —— 转屏不再静默丢掉已取走的分享(面板状态与描述移进 ChatUiState,take 仍只发生一次) 2026-10-06 21:26:29 +08:00
AskaEth b3ded77c23 merge: W54A —— 通知一事一条(通知 id / PendingIntent requestCode 按消息稳定派生,修她连说几句时后到覆盖先到) 2026-10-06 21:26:26 +08:00
AskaEth 464e91621e fix(parse): 容错落单的段落标签,绝不把 <p> 当正文渲染
真机实测:后端产出的某条消息第三段是 `<p><p>那现在…?</p>`(两个开标签、一个闭标签)。
P_BLOCK_RE 是非贪婪的,先把外层 `<p>` 配到那个 `</p>`,内层那个裸 `<p>` 就成了「块内容」;
而 appendAnnotations 只认 `<action>` / `<narration>` / 星号,不认裸 `<p>`,于是它作为普通文本
落进气泡正文,屏幕上直接显示 `<p>那现在…`。

改了什么:新增 stripLooseTags,在切出 `<p>` 块之后按区剥掉「落单标签」——
- 消息里已有完整 `<p>…</p>` 块时,块内块外多出来的每个 `<p>` / `</p>` 一律剥掉,块外没被
  ROLE_RE 认领的 `<action>` / `</action>` 同样剥掉;
- 一个完整块都没有时,维持既有「未闭合 `<p>` 整段回落字面文本」的契约(编辑框靠它保证不
  静默改字),只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>` 与后面什么都没有的空开标签。
判据只认完整匹配已知标签词表的形状,`1 < 2` / `<3` / `a<b` / `<p2>` / `<P>` / `<p`(残缺) 一律原样保留。

为什么选择「剥」而不是「原样显示」:这些是服务端产出的标记、不是她说的话;渲染成正文等于往
气泡里塞一串用户既看不懂、也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字
都不动。这只是客户端的容错,不代表服务端这样发是对的。

流式:stablePrefixEnd 不动。扣留点取「第一个没闭合的开标签」,而前缀里的每个开标签必定在同一个
前缀内配到闭标签,所以「是不是落单」在它被产出的那一刻就已定死,中间态剥掉、收口也不用改口;
放宽扣留(把重复的 `<p><p>` 当外层冗余)反而会让 `<p>甲<p>乙` 在中间态把 `<p>甲` 当字面文本吐上屏,
等 `</p>` 到了再跳成 `甲乙`。新增测试钉住这条:逐字符喂实测畸形串时,任何一帧都不许漏出标签,
且已显示的正文只许增不许改口。

测试:assembleDebug 通过;ChatContentParserTest 55 项全绿(新增 17 项);全量 110 suites /
1349 tests / 0 failures / 0 errors(主仓同 HEAD 基线 110 suites / 1332 tests,本次 +17 项)。
2026-10-06 21:26:07 +08:00
AskaEth ec89d828d1 fix(chat): 分享确认面板的正文预览改成三行窗口 + 内部滚动
问题:正文那一窗此前是 maxLines = 3 + 省略号,超过三行的分享正文在按「发送」之前根本读不到。
面板里再没有第二处放着这段正文(输入框只剩「用户附加的描述」,初值恒为空),截断就等于
「要发出去的那段正文看不见」——而这正是用户点「发送」时唯一要确认的东西。

改法:正文那一窗高度恒为三行、内部可滚,外层预览区那 150dp 的上限原样不动,所以面板不会被
长正文撑高(图片 / 文件芯片 / 按钮的位置都不变)。滚到底后余量交给外层 scroll,嵌套滚动本身
就有,不用额外接线。

上限为什么不是写死的 dp:Miuix 的 footnote1 只给了 13sp 的字号,lineHeight 是 Unspecified,
行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。写死 dp 在默认字号档上勉强
对得上,到大字号档就会矮一截、把末行的字切一半。所以改为量:从 TextLayoutResult 上取第三行的
底边当上限,量到之前先按无上限排一次(heightIn(max = Dp.Infinity) 在确认过 Compose 的
Density.roundToPx 对 Infinity 的处理后是安全的,即「没有上限」)。

SHARE_PREVIEW_TEXT_LINES 由 private 改 internal,只为让单测能把「这一窗是三行」钉成字面量
(本模块没有 Compose UI 测试的依赖,滚动行为单测够不着)。

测试:新增两条——正文远超三行时纯函数层仍原样交付**完整**正文(一个字不少、不加省略号),
以及三行上限的字面量;既有断言一条未放松。
2026-10-06 21:11:30 +08:00
AskaEth 1142bf5ed1 fix(share): 转屏不再静默丢掉「已取走、还没确认」的那份分享 —— 面板状态移进 ChatViewModel
分享进来的内容被 `ShareIntake.take()` 取走之后没有归还路径,而它此前只挂在一格
`remember(sessionId)` 上:Activity 因转屏 / 深浅色切换重建时那次组合整份丢掉,那份分享
**既不在待发队列里、也不在界面上**(用户什么都没看到,也没有任何提示);用户已经输入到
一半的那句描述同样丢。

改法:把「已取走、正等确认的那份分享 + 用户补的描述」移进 `ChatUiState.shareConfirm`
(`ShareConfirmDraft`),那一格由 VM 持有,而 VM 活在 `ChatSessionSlots` 的 `ViewModelStore`
里、由 AppContainer 撑着 —— 比组合与 Activity 都活得久,所以重建之后面板照旧长出来,
描述也跟着回来(描述与正文绑在同一个值里,两者同生共死)。

`take()` 仍然只发生一次:认领那一步收进 `ChatViewModel.claimSharedContent`,闸门是纯函数
`shareConfirmClaim`,且**在 `take()` 之前** —— 手上已经有那份内容时一个字节都不动队列
(既不取新的一条,也不会把用户正看着的那份顶掉)。重建后的新一帧照样会走到判据这里,
而那时手上已经有东西(它活在 VM 里)⇒ 不会再取第二次。判据、出队、落地之间没有挂起点,
所以也不存在「取走了却没记住」的窗口。

顺带把这一格计入 `holdsUnsentWork`:这份内容已经没有退路,那一格被回收就等于把它删掉。
`ShareLanding` / `ShareIntake.take()` 里「取走之后直接发」的旧说法一并改成「交给确认面板」。

新增 ShareConfirmDraftTest:钉住「重建后的第二帧不再调 take、队列里那条也不许被取走」、
「内容与描述原样还在」、「队列为空时不造一个空面板」。
2026-10-06 21:09:15 +08:00
AskaEth 53339b369a feat(notify): 她主动说的话改成「一事一条」——通知号按消息派生,后到的不再顶掉先到的
改前所有她主动说的话共用一个固定的通知 id,而同 id 的 `notify` 是**替换**:她连说几句时后来
的那条把先来的顶掉,用户拿起手机只看到最后一句,前面几句一声没响就没了(这正是 W50 判据下游
的落地形状)。

改成按消息派生:
- `ProactiveNotify.kt` 新增纯函数 `proactiveNotificationId(key)`:把**去重键**(`proactiveNotifyKey`
  的输出)折成通知号。刻意与去重键同源——去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」,
  两处口径一旦分成两套,就会出现「认得出是同一条、却排出两条通知」的形状漂移(实时帧与落库帧
  两条路都会到,这条尤其要命)。
- 两个坑都真的堵上:① 号必须 > 0,而 `hashCode()` 可以是负的、甚至正好是 `Int.MIN_VALUE`
  (`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)⇒ **先展宽到 `Long` 再取绝对值**,不引任何新依赖;
  ② 不撞既有 id:全仓只有保活那条常驻通知(id = 1)与它 `PendingIntent` 的 request code = 0 是
  占用者,派生的号从 1000 起步、宽度 2×10⁹,落点与它们**结构性**不相交。
- `ProactiveNotifier.post` 的签名带上这个号(`post: (text: String, notificationId: Int) -> Unit`),
  `AppContainer` 的装配 lambda 同步改;`ProactiveNotification.kt` 删掉恒定的
  `PROACTIVE_NOTIFICATION_ID` / `PROACTIVE_OPEN_REQUEST_CODE`,通知 id 与 `PendingIntent` 的
  request code **一起**随消息走(只换 id 不换 request code,点后到的那条通知会串到先到那条的
  `PendingIntent` 上)。
- 撞号仍会发生(`String.hashCode()` 只有 32 位):后果与改前一样是「后来者替换先来者」,已在
  `proactiveNotificationId` 的 KDoc 里如实写清概率,以及为什么接受这一侧。**没有**放松任何既有断言。

测试:新增 `ProactiveNotificationIdTest`(同一条派生两次同号 / 两条不同消息不同号 / 任何输入恒 > 0,
最后一条用 hashCode 正好是 `Int.MIN_VALUE` 的字符串 `polygenelubricants` 钉住前提);另在
`ProactiveNotifierTest` 加一条端到端的号断言(两条路都到的第一条只发一次且号不变、第二条是另一个
号)。`ProactiveNotificationShapeTest` 里那条「不与保活那条相撞」改成扫一遍真实形状的键。

验证:`./gradlew.bat assembleDebug` → BUILD SUCCESSFUL;`./gradlew.bat testDebugUnitTest` →
BUILD SUCCESSFUL,111 suite / 1336 tests / 0 失败(基线 110 suite / 1332 tests,本批净增 4 条)。
2026-10-06 21:08:23 +08:00
17 changed files with 842 additions and 115 deletions

No files matched your search

@@ -70,7 +70,7 @@ class AppContainer(context: Context) {
*/
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
presence = appPresence,
post = { text -> postProactiveNotification(appContext, text) },
post = { text, notificationId -> postProactiveNotification(appContext, text, notificationId) },
)
/**
@@ -139,8 +139,12 @@ object ShareIntake {
/**
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
*
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
* 出队是**唯一的认领方式**,而它在这里是原子的:两个消费者同时来也只会各拿各的一条
* (判据见 [PendingShareHolder.take])。而「**已经认领过了**」这件事此后记在认领它的那一格上
* ——`ChatViewModel` 的 [top.yeij.cyrene.ui.chat.ChatUiState.shareConfirm]。那一格活在
* `AppContainer` 那一层(`core.session.ChatSessionSlots` 的 `ViewModelStore`),比组合与
* Activity 都活得久,所以转屏 / 深浅色切换重建之后**不会再取第二遍**,也不会把队列里下一条顶掉:
* 认领的闸门在 `ChatViewModel.claimSharedContent` 里,且在调用本函数**之前**。
*/
fun take(): SharedContent? = holder.take()
@@ -31,12 +31,6 @@ internal const val PROACTIVE_CHANNEL_ID = "proactive"
*/
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
/**
* 通知 id。**必须**与保活那条(`KEEP_ALIVE_NOTIFICATION_ID` = 1)分开:同 id 的 `notify` 是
* **替换**,撞上就是把「正在后台保持连接」那条常驻通知顶掉(前台服务还在跑,而它的通知没了)。
*/
internal const val PROACTIVE_NOTIFICATION_ID = 2
/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */
internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
@@ -55,21 +49,17 @@ internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话"
*/
internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PROACTIVE_OPEN_MAIN_SESSION"
/**
* 那个 `PendingIntent` 的 request code。
*
* ⚠️ **绝不能和保活那条用同一个**(那边是 `0`):两个 Intent 都是「启动 MainActivity」、没有
* action / data / categories ⇒ `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等
* 判据里**不含 extras**。同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被
* 悄悄丢掉——症状是「点通知进来了,但停在别的会话上,什么都没发生」。
*/
private const val PROACTIVE_OPEN_REQUEST_CODE = 2
/**
* 弹一条「她主动说的话」的通知。
*
* 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据与去重都不在这里**
* (那是 [ProactiveNotifier] 的事)——本函数被调用就意味着「这一次确实该弹」。
* 只做三件事:建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、去重与 id 的派生
* 都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)——本函数被调用就
* 意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
*
* ## 为什么 id 是入参而不是这里恒定一个
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
*
* ## 没授通知权限时什么都不做
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
@@ -81,15 +71,22 @@ private const val PROACTIVE_OPEN_REQUEST_CODE = 2
* 工程里没有为通知准备单色小图标(`@mipmap/ic_launcher` 是彩色的,拿它当通知小图标在通知栏上
* 会显示成一块白斑)。所以用平台自带的 `stat_notify_chat`——与保活那条用 `stat_notify_sync` 是
* 同一个做法。要换成本应用的图标时,只需换下面这一行。
*
* @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动:
* 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。
*/
internal fun postProactiveNotification(context: Context, text: String) {
internal fun postProactiveNotification(context: Context, text: String, notificationId: Int) {
if (!context.notificationsAllowed()) return
ensureProactiveChannel(context)
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
// `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等判据里**不含 extras**。
// 同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被悄悄丢掉。
val openApp = PendingIntent.getActivity(
context,
PROACTIVE_OPEN_REQUEST_CODE,
notificationId,
Intent(context, MainActivity::class.java).putExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, true),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
@@ -108,10 +105,9 @@ internal fun postProactiveNotification(context: Context, text: String) {
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
// 恒用同一个 id:她连说几句时**后来的覆盖前面的**,通知栏里只留最新一句。
// 这是刻意的取舍——通知只是「她找你了」这一下提醒,全部内容都在聊天页里;
// 换成一事一条会把通知栏刷满,而她的话本来就都在主会话里躺着。
NotificationManagerCompat.from(context).notify(PROACTIVE_NOTIFICATION_ID, notification)
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
// 理由与概率写在 `proactiveNotificationId` 的 KDoc 里。
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
/**
@@ -51,12 +51,14 @@ enum class ProactiveNotifyOutcome {
* 4. [ProactiveNotifyOutcome.SeenOnScreen] / [ProactiveNotifyOutcome.Posted]:最后才问时机。
*
* @param presence 「App 在不在前台 / 聊天页显示着谁」,由 `MainActivity` 与 `ChatScreen` 报告。
* @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。测试传一个记录用的假实现即可。
* @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。第二个入参是**这条消息派生出来的
* 通知 id**(见 [proactiveNotificationId]):落地侧必须用它,别再恒用一个号——恒用一个号时
* `notify` 是替换,她连说几句就只剩最后一句。测试传一个记录用的假实现即可。
* @param mainSessionId 主会话 id。默认取 [SessionSummaryDto.MAIN_SESSION_ID],测试可换。
*/
class ProactiveNotifier(
private val presence: AppPresence,
private val post: (String) -> Unit,
private val post: (text: String, notificationId: Int) -> Unit,
private val mainSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
private val recent: RecentProactiveNotices = RecentProactiveNotices(),
) {
@@ -84,7 +86,8 @@ class ProactiveNotifier(
val text = proactiveNotificationText(content) ?: return ProactiveNotifyOutcome.NothingToSay
val firstArrival = recent.markIfNew(proactiveNotifyKey(id, messageId, timestamp, text))
val key = proactiveNotifyKey(id, messageId, timestamp, text)
val firstArrival = recent.markIfNew(key)
if (!firstArrival) return ProactiveNotifyOutcome.Duplicate
// 判据只回答「弹不弹」,「用户会不会正好看到」是它的反面——别在接线层另写一遍这个取反
@@ -95,7 +98,8 @@ class ProactiveNotifier(
)
if (seenOnScreen) return ProactiveNotifyOutcome.SeenOnScreen
post(text)
// id 与去重键同源,正是为了让「同一条 ⇒ 同一个号」与「同一条 ⇒ 只弹一次」永远不会分叉。
post(text, proactiveNotificationId(key))
return ProactiveNotifyOutcome.Posted
}
}
@@ -1,5 +1,6 @@
package top.yeij.cyrene.core.notify
import kotlin.math.absoluteValue
import top.yeij.cyrene.domain.parse.parse
/**
@@ -110,6 +111,54 @@ fun proactiveNotifyKey(
.firstOrNull { !it.isNullOrBlank() }
?: fallback
/**
* 派生 id 的取值**下界**(见 [proactiveNotificationId])。
*
* 通知 id 与 `PendingIntent` 的 request code 都是**全 App 共用**的命名空间,而它们现有的占用者
* (保活那条常驻通知 `KEEP_ALIVE_NOTIFICATION_ID` = 1、它点击用的 request code = 0)全在低位。
* 从远高于它们的地方起步,「派生 id 不撞既有 id」就是**结构性**成立的,不是靠「碰巧没撞上」。
*/
private const val PROACTIVE_NOTIFICATION_ID_FLOOR = 1_000
/**
* 派生 id 的取值**宽度**(见 [proactiveNotificationId])。
*
* 取这么宽是为了让 id 空间尽量大(撞号的概率随宽度下降),同时**贴着 `Int.MAX_VALUE` 留出下界的
* 位置**:「下界 + 宽度」一旦越过 `Int.MAX_VALUE`,末尾的 `.toInt()` 就会折回负数。
*/
private const val PROACTIVE_NOTIFICATION_ID_SPAN = 2_000_000_000
/**
* 一条 proactive 消息对应的**通知 id(同时也是它点击用的 request code)**——纯函数,由去重键
* [proactiveNotifyKey] 的输出派生。这就是「一事一条」的全部落地形状(W54A)。
*
* ## 为什么必须与去重键同源
* 改前所有她的话共用一个固定 id,而 `notify` 遇同 id 是**替换**:她连说几句,用户只看到最后
* 一句,前面那几句在他拿起手机之前就已经没了。按消息派生要回答的第一个问题是「凭什么是同一条」
* ——那正是 [proactiveNotifyKey] 已经回答过的(实时帧与落库帧共用它)。**两处口径必须永远同源**:
* 去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」;分成两套,就会出现「认得出是同一条、
* 却排出两条 id」的形状漂移。
*
* ## 它会撞号,后果如实说
* `String.hashCode()` 只有 32 位,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 这一段 ⇒ **不同消息
* 派生出同一个 id 是会发生的**(约 2×10⁹ 的空间,按生日问题算,通知栏里装几十条时概率可忽略)。
* 撞上的后果与改前**一模一样**:后来那条替换先来那条,用户少看见一条提醒。这是刻意接受的那一侧
* ——通知只是「她找你了」这一下,她的话本身在主会话里一条不少;反过来要绝对不撞,就得在服务端
* 唯一号之外再维护一套映射并持久化它,代价比「极少数情况下少一条提醒」大得多。
*
* ## 两个坑都在这里堵上
* 1. **号必须为正**:通知 id 是一个有符号 `int`,而这个命名空间里正数是纪律(本仓保活那条常驻通知
* 是 1;平台对前台服务的 id 更直接写明「must not be 0」)。而 `hashCode()` 可以是负的、甚至
* 正好是 `Int.MIN_VALUE`——后者连 `abs` 都兜不住(在 int 域里 `abs(Int.MIN_VALUE)` 仍是它自己)。
* 所以这里**先展宽到 `Long` 再取绝对值**:`Int.MIN_VALUE.toLong()` = 2147483648,取绝对值是安全
* 的,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 后落在正区间。
* 2. **不撞既有 id**:见 [PROACTIVE_NOTIFICATION_ID_FLOOR]。
*/
internal fun proactiveNotificationId(key: String): Int {
val magnitude = key.hashCode().toLong().absoluteValue
return (PROACTIVE_NOTIFICATION_ID_FLOOR + magnitude % PROACTIVE_NOTIFICATION_ID_SPAN).toInt()
}
/**
* 通知正文:她的原话,**剥掉标记、折成一行、过长截断**;没有可显示的字时回 `null`。
*
@@ -130,7 +130,8 @@ data class ConclusionParagraph(val parts: List<ContentPart>)
*
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底
* (落单的段落标签剥掉,其余按普通文本回落)。
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
* `null` / 纯空白输入返回空列表。
@@ -19,7 +19,20 @@ import kotlin.math.min
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
* 也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字都不动。
* 这只是客户端的容错,不代表服务端这样发是对的。
* 判据([stripLooseTags])分两种情形:消息里**已有完整块**时,段落结构已被完整块定死,块内
* 块外多出来的每个 `<p>` / `</p>`、以及块外孤立的旁白标签,都是冗余噪声,一律剥掉(块内的
* 落单旁白标签例外,见最后一条);消息里**一个完整块都没有**时,按上一条整段回落字面文本,
* 只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。
* 只认「确定是标签」的形状:`<p>` / `<p 属性>` / `</p>` / `<action>` 等。`1 < 2`、`<3`、
* `a<b`、`<p2>`、`<P>`(词表只有小写,与 `MessageEditText` 同一口径)、`<p`(残缺,没有 `>`)
* 都原样保留——见到 `<` 就吃,才是真正会丢字的那种错。
* - 未闭合/畸形的其余标签一律回落为普通文本,绝不抛异常。块内没配对的 `<action>` / `</action>`
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
@@ -37,6 +50,15 @@ import kotlin.math.min
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
* 扣留点取「第一个没闭合的开标签」,而落在前缀里的每个 `<p>` / `<action>` 开标签必定在
* **同一个前缀内**配到闭合标签(否则它就是那个没闭合的,扣留点会落在它前面)。所以
* 「这个标签是不是落单的」在它被产出的那一刻就已经定死,中间态剥掉、收口时也不用再改口。
* - 反过来放宽扣留是不行的:把重复的 `<p><p>` 当「外层冗余」让前缀继续推进,`<p>甲<p>乙`
* 就会在中间态把 `<p>甲` 当字面文本吐上屏,等 `</p>` 到了又变成 `甲乙`——字会跳。
* 剥标签与扣留的标签词表因此必须逐字一致(共用 OPEN_P_TAG_RE / CLOSE_P_TAG_RE /
* OPEN_ROLE_TAG_RE / CLOSE_ROLE_TAG_RE,无大小写、无词表扩展),两边一旦分叉,
* 就会出现「中间态当正文、收口时又剥掉」的跳动。
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
*/
@@ -91,6 +113,10 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
private const val FULL_STAR_MAX_LEN = 40
/**
* 已知标签词表(只有小写,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面这组同词表的正则,两边必须同源。
*/
private val TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
@@ -144,29 +170,112 @@ private sealed interface Tok {
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
appendAnnotations(raw, out, splitBlankLines = true)
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
appendAnnotations(
stripLooseTags(raw, markupMode = false, stripStrayRoleTags = false),
out,
splitBlankLines = true,
)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
if (lead.isNotEmpty()) {
appendAnnotations(stripLooseTags(lead, true, stripStrayRoleTags = true), out, splitBlankLines = false)
}
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
if (inner.isNotEmpty()) {
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
appendAnnotations(stripLooseTags(inner, true, stripStrayRoleTags = false), out, splitBlankLines = false)
}
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
if (trail.isNotEmpty()) {
appendAnnotations(stripLooseTags(trail, true, stripStrayRoleTags = true), out, splitBlankLines = false)
}
return out
}
/**
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
*
* [markupMode] = 这条消息里**已有完整 `<p>…</p>` 块**,段落结构已被完整块定死,于是块内/块外
* 多出来的每一个 `<p>` / `</p>` 都是冗余噪声,一律剥掉。
* 没有完整块时(`parse` 会整段回落字面文本,见 `unclosedParagraphStaysLiteralText`;编辑框也靠
* 这条保证不静默改字,见 `MessageEditTextTest.没闭合的 p 不抛异常`)只再剥两类不可能是正文的东西:
* 落单的 `</p>`,以及后面什么都没有的**空**开标签——它们连「被截断的段落」都算不上。
*
* [stripStrayRoleTags] 只对**块外**文本区为 true:那里的 `<action>` / `</action>` 若没被 [ROLE_RE]
* 认领(即没配成对),就是落单标签,剥掉。已配对的必须留给 [ROLE_RE] 解析成 Action/Narration,
* 提前剥掉等于把旁白降级为正文(`textOutsideParagraphsIsPreserved` 钉住的行为)。
*/
private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String {
if (text.indexOf('<') < 0) return text
val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList()
val out = StringBuilder(text.length)
var last = 0
var i = 0
while (i < text.length) {
val lt = text.indexOf('<', i)
if (lt < 0) break
val gt = text.indexOf('>', lt)
if (gt < 0) break
val tag = text.substring(lt, gt + 1)
val drop = when {
OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1)
CLOSE_P_TAG_RE.matches(tag) -> true
!stripStrayRoleTags -> false
roleRanges.any { lt in it } -> false
else -> OPEN_ROLE_TAG_RE.matches(tag) || CLOSE_ROLE_TAG_RE.matches(tag)
}
if (drop) {
out.append(text, last, lt)
last = gt + 1
i = last
} else {
i = lt + 1
}
}
if (last == 0) return text
out.append(text, last, text.length)
return out.toString()
}
/** [from] 之后是否还有正文:跳过空白与后续的 `<p>` 族标签后仍非空即为有。 */
private fun hasContentAfter(text: String, from: Int): Boolean {
var i = from
while (i < text.length) {
if (text[i].isWhitespace()) {
i++
continue
}
if (text[i] == '<') {
val gt = text.indexOf('>', i)
if (gt >= 0) {
val tag = text.substring(i, gt + 1)
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) {
i = gt + 1
continue
}
}
}
return true
}
return false
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
var last = 0
@@ -405,7 +405,7 @@ fun ChatScreen(
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废,
// 挑时机、请 VM 认领一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废,
// 2026-10-06 主人要求像其他 IM 一样先确认——只有点「发送」才真的发)。
// 判据本身是纯函数 [shareLanding](可单测)。
// - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
@@ -422,11 +422,12 @@ fun ChatScreen(
// 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
// effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。
// - **取走只发生一次,而且只发生在面板没开的时候**:取到的那一份存进下面那格 Compose 状态,
// 面板(`ShareConfirmPanel`)靠它渲染。effect 的键里有连接状态 ⇒ 它会反复重跑,而重跑
// **绝不会**再取一份、也绝不会重复发送:`take()` 只在 `shareConfirm == null` 时被调用一次,
// 取走之后队列已空(判据看到的是 `Nothing`),而面板开着时新来的分享**原地在队列里等**——
// 键里的 `shareConfirm == null` 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。
// - **取走只发生一次,而且只发生在「手上什么都没有」的那一帧**:取到的那一份落进**VM 的状态**
// ([ChatUiState.shareConfirm],不是这里的一格 `remember`),面板靠它渲染。effect 的键里有连接
// 状态 ⇒ 它会反复重跑,而重跑**绝不会**再取一份、也绝不会重复发送:闸门在 `take()` **之前**,
// 而闸门的依据就是 VM 里那一格(`ChatViewModel.claimSharedContent` 的 KDoc 写着它为什么在
// `take()` 之前)。面板开着时新来的分享**原地在队列里等**——键里的 `shareConfirm == null`
// 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。
val pendingShares by ShareIntake.pending.collectAsState()
val shareConnection = state.connection
// 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走:
@@ -436,17 +437,13 @@ fun ChatScreen(
// 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑)
// 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。
var shareToldWaiting by remember(sessionId) { mutableStateOf(false) }
// 「这一份分享已经取走、正在等用户确认」。它此前**没有地方待**——取到就直接送进发送链了;
// 现在取到的那一份停在这格 Compose 状态里,确认面板靠它渲染(`ShareConfirmPanel`)。
//
// 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着,
// 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。
var shareConfirm by remember(sessionId) { mutableStateOf<SharedContent?>(null) }
// 面板输入框里的文字 = **用户附加的描述**(W53X3 更正:它不再是分享正文的复刻,初值恒为空)。
// 它与上面那份内容**分开存**是刻意的:预览那一份说的是「分享进来的是什么」,这格说的是
// 「用户想补一句什么」,两者本来就是两件事,合成一个字段会让预览跟着输入框一起变。
// 真正发出去的正文由纯函数 [shareConfirmBody] 在按下「发送」那一刻拼(分享正文 + 这段描述)。
var shareCaption by remember(sessionId) { mutableStateOf("") }
// 「一份分享已经取走、正在等用户确认」+ 用户补的那句描述。**它们刻意不在 Compose 里**:那一份
// 已经出队、没有归还路径,而这一格 `remember` 会随转屏 / 深浅色切换整份丢掉——丢掉的后果是
// 那份分享既不在队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。
// 所以它住在 VM 的状态里(面板输入框那句描述与正文绑在同一个值里,见 [ShareConfirmDraft]):
// `ChatSessionSlots` 的那一格 `ViewModelStore` 由 `AppContainer` 持有,比 Activity 活得久。
// ⚠️ 别把它挪回这里——「比 Activity 活得久」正是它存在的全部理由。
val shareConfirm = state.shareConfirm
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
shareWaitedLong = false
@@ -469,19 +466,14 @@ fun ChatScreen(
when (landing) {
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
//
// **取走只在这里、且只在面板没开的时候**:`shareConfirm == null` 既是这条闸门,也是本
// effect 的键之一。面板开着时来的新分享一个字节都不动(原地留在待发队列里,等这一份
// 确认完再接)——那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。
// **取走只在这一处发生**,而且闸门在 `take()` **之前**(判据是纯函数 [shareConfirmClaim],
// 落在 `ChatViewModel.claimSharedContent` 里):手上已经有了那份未确认的分享时,这一帧
// 一个字节都不动队列——面板开着时来的新分享原地留在待发队列里,等这一份确认完再接,
// 那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。转屏重建之后新的一帧照样走到
// 这里,而那时**手上已经有东西**(它活在 VM 的状态里),所以也不会再取一次。
ShareLanding.Send -> {
shareToldWaiting = false
if (shareConfirm == null) {
ShareIntake.take()?.let { share ->
shareConfirm = share
// 描述从空开始:它是用户要附加的话,**不是**这次分享进来的正文——
// 预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
shareCaption = ""
}
}
viewModel.claimSharedContent()
}
// 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定,
@@ -824,34 +816,19 @@ fun ChatScreen(
// 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还
// 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与
// 描述都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。
// 描述都在 **VM 的状态**里(`state.shareConfirm`,理由见那里与 `ChatViewModel.claimSharedContent`
// 的 KDoc),面板本身与它的判据都在 `ShareConfirmPanel.kt`。转屏重建之后面板照旧在这里长出来,
// 因为 VM 比 Activity 活得久;**它绝不能被挪回这个组合里**。
//
// ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**:
// 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。
shareConfirm?.let { share ->
shareConfirm?.let { draft ->
ShareConfirmPanel(
share = share,
caption = shareCaption,
onCaptionChange = { shareCaption = it },
onSend = {
// 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。
//
// 正文 = 分享自带的那一段 + 用户补的那一句,由纯函数 [shareConfirmBody] 拼
// (两段都在时**空行**分隔):W53X3 起输入框只代表「用户附加的描述」,所以这里
// **不能**再直接拿输入框的内容当正文——那正是「同一句话排两遍」那一版的错法。
val sent = share.copy(text = shareConfirmBody(share, shareCaption))
shareConfirm = null
shareCaption = ""
// 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文里可能刚多了用户
// 补的那一句,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符
// 的话。(`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决
// 也一字未动——现在参与那条裁决的是**拼好之后**的整段正文,那才是真发出去的东西。)
viewModel.sendShared(sent, shareNoticeFor(sent))
},
onCancel = {
shareConfirm = null
shareCaption = ""
},
share = draft.share,
caption = draft.caption,
onCaptionChange = viewModel::onShareCaptionChange,
onSend = viewModel::sendShareConfirm,
onCancel = viewModel::cancelShareConfirm,
)
}
}
@@ -633,6 +633,15 @@ data class ChatUiState(
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生(或刚刚等着)的一次动作。
*/
val shareNotice: String? = null,
/**
* 面板手上正拿着的那份分享(**已经出队**)+ 用户补的那句描述;null = 没有面板。
*
* 它**必须**在这里(而不是一格 `remember`):内容出队之后没有归还路径,而 Activity 转屏 /
* 深浅色切换会让组合整份重建——挂在组合上的那一格一丢,那份分享就从世界上消失了(队列里没有、
* 界面上也没有,用户什么都没看到)。理由与「正文 + 描述为什么绑在一个值里」见
* [ShareConfirmDraft] 的 KDoc;认领那一步见 [ChatViewModel.claimSharedContent]。
*/
val shareConfirm: ShareConfirmDraft? = null,
/**
* 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。
*
@@ -816,6 +825,11 @@ class ChatViewModel(
* - [ChatUiState.queuedForConnection]:一次都还没上过路、在等连接的那几条(内容挂在 `pending`
* 那条气泡上,两者同生共死;这里两样都写,是为了将来只改其中一处时仍然拦得住)。
*
* 第四个来源 [ChatUiState.shareConfirm] 是**同上**的一格,只是更没有退路:那份分享已经从待发
* 队列里出队(没有归还路径),把这一格交出去等于把它删掉——界面上那个确认面板会跟着这一格一起
* 消失,而它连输入栏都回不去。它同样只在「本端唯一一份」这个意义上算数(用户已经输入到面板里的
* 描述也算,两者本来就绑在同一个值里)。
*
* 流式中的回复([ChatUiState.streams])**刻意不算**:那是服务端正在生成的那一份的本地投影,
* 本端这一格没了那一轮照旧跑完、照旧落库,重进会话会重新拉到;它也不具备「本端唯一」这条性质
* (而它正是「回收能省下多少」的大头之一,把它算进来等于让回收在这最该省的时候不动手)。
@@ -826,7 +840,8 @@ class ChatViewModel(
it.draftImages.isNotEmpty() ||
it.draftDocuments.isNotEmpty() ||
it.pending.isNotEmpty() ||
it.queuedForConnection.isNotEmpty()
it.queuedForConnection.isNotEmpty() ||
it.shareConfirm != null
}
private var socket: ChatSocket? = null
@@ -1458,6 +1473,74 @@ class ChatViewModel(
}
}
/**
* 认出「这一帧该落一份分享」([ShareLanding.Send]):手上没有待确认的那份时,去待发队列认领一条。
*
* ## 为什么认领与保管在同一处、而且都在 VM 里
* `ShareIntake.take()` **不可逆**(原子出队、没有归还路径),而触发认领的那一帧随时会消失:
* Activity 因转屏 / 深浅色切换重建会让组合整份丢掉。此前那份内容只挂在一格 `remember(sessionId)`
* 上 ⇒ 重建之后它既不在队列里(已经出队)、也不在界面上(那格状态没了),**静默丢失**。
* 现在认领与保管是同一件事:一取走就落进 [ChatUiState.shareConfirm],而那一格活在
* `core.session.ChatSessionSlots` 的 `ViewModelStore` 里(由 `AppContainer` 持有,比 Activity 久)。
*
* ## 闸门在 `take()` **之前**
* 重建之后新的一帧照样会走到这里,而它看见手上已经有东西 ⇒ **一个字节都不动队列**:既不取新的
* 一条,也不会把用户正看着的那份顶掉(判据是纯函数 [shareConfirmClaim],可单测)。于是「这份分享
* 是在哪一步、被谁、取走了几次」有一句能说清的话:**只由当前会话这个 VM**、在**手上为空的那一帧**
* 取走**一次**——判据、出队、落地三步之间没有挂起点,所以不存在「取走了却没记住」的窗口。
*
* 新认领的那一份描述从空开始([ShareConfirmDraft.caption] 的初值):它是用户要附加的话、**不是**
* 这次分享进来的正文——预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
*/
fun claimSharedContent() {
val holding = _state.value.shareConfirm
val claimed = shareConfirmClaim(holding) { ShareIntake.take() }
if (claimed != null && claimed !== holding) {
_state.update { it.copy(shareConfirm = claimed) }
}
}
/**
* 面板输入框里的那句描述变了。
*
* 它与正文**同生共死**(两者都在 [ChatUiState.shareConfirm] 里),所以它活得过转屏重建——
* 用户打到一半的描述不会因为屏幕转了一下就没了。手上没有那一份时什么都不做:没有面板的地方
* 不该攒下一句没主的描述。
*/
fun onShareCaptionChange(caption: String) {
_state.update { state ->
state.shareConfirm?.let { state.copy(shareConfirm = it.copy(caption = caption)) } ?: state
}
}
/**
* 用户把面板撤了:这一份作废。
*
* 刻意**不把内容塞回待发队列**([top.yeij.cyrene.domain.share.PendingShareHolder] 的 KDoc 里
* 写着理由):塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也
* 分不开了。撤回的是**这一格**,队列里别的内容一个字节都不动。
*/
fun cancelShareConfirm() {
_state.update { it.copy(shareConfirm = null) }
}
/**
* 用户点了面板上的「发送」:把**拼好的那一段正文**交给既有发送路径([sendShared])。
*
* 正文由纯函数 [shareConfirmBody] 拼(分享自带的正文 + 用户补的描述,两段都在时**空行**分隔),
* 而告知按**真正发出去的那一份**算(`sent`,不是拼之前那份):正文里可能刚多了用户补的那一句,
* 拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符的话。
*
* 先把面板那一格清空再发是刻意的:这一次发送与面板本身互不相干,而它紧接着的那次重组里
* 已经不该在了。
*/
fun sendShareConfirm() {
val draft = _state.value.shareConfirm ?: return
val sent = draft.share.copy(text = shareConfirmBody(draft.share, draft.caption))
_state.update { it.copy(shareConfirm = null) }
sendShared(sent, shareNoticeFor(sent))
}
/**
* 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。
*
@@ -0,0 +1,51 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.domain.share.SharedContent
/**
* 「一份**已经取走**、正等着用户确认的分享」,外加用户此刻补的那句描述。
*
* ## 为什么它必须活在 VM 里,而不是一格 `remember`
* 这份内容**已经**从待发队列里出队了([top.yeij.cyrene.app.ShareIntake.take] 是原子出队、
* **没有归还路径**),而它此前只挂在一格 `remember(sessionId)` 上——那格状态属于**那一次组合**。
* Activity 因转屏 / 深浅色切换重建时组合整份丢掉:那份分享既不在队列里(已经出队)、也不在界面上
* (那格状态没了),**静默消失**——用户什么都没看到,也没有任何提示。所以它待在
* [ChatUiState.shareConfirm] 里:VM 由 `AppContainer` 那一层持有(`core.session.ChatSessionSlots`
* 的 `ViewModelStore`),比组合与 Activity 都活得久。
*
* 正文([share])与描述([caption])**绑在同一个值里**是刻意的:描述说的是「你要补的那一句话」,
* 它只对这一次确认有意义——分成两格迟早会出现「描述换了一份、正文还是上一份」。
*
* [caption] 初值**恒为空**:面板上半是这次分享进来的东西,下半才是用户要附加的话,预填一次就会在
* 面板里把同一句话排两遍(W53X 的第一版正是这么错的,见 `ShareConfirmPanel` 的 KDoc)。
*/
data class ShareConfirmDraft(
val share: SharedContent,
val caption: String = "",
)
/**
* 「手上这一份 + 去待发队列认领一次」⇒ 面板这一刻该拿着哪一份(**纯函数**,可单测)。
*
* ## 它回答的是**不可逆那一步该不该走**
* [take] 就是 `ShareIntake.take()`(**不可逆**),所以「什么时候允许调它」是这里唯一的正事:
* **手上已经有东西时一个字节都不动**——连队列里新来的那条也不碰,它原地等着,面板上此刻摆的
* 仍是用户正在看的那一份,不会被后来的一条顶掉。这就是「同一份分享只被取走一次」的落点:
* Activity 重建之后新的一帧会再问一次同样的问题,而那时 [holding] 里已经装着那份内容
* (它活在 VM 的状态里,活过了重建)⇒ 不会再 [take] 第二次。
*
* ## 为什么不是「取完之后再拿结果比一比」
* 不能靠「第二次 [take] 拿到 null 就当没发生过」兜底:第二次跑起来拿到的要么是 null(白跑一趟),
* 要么是**队列里的下一条**——后者会把用户正看着的那份顶掉,那是一次静默丢失。所以闸门必须在
* [take] **之前**,而不是在它之后回滚。
*
* @param holding 这一刻面板手上那一份([ChatUiState.shareConfirm]);null = 手上没东西。
* @param take 去待发队列认领一条(`ShareIntake.take()`):**只在 [holding] 为 null 时被调用**,
* 且最多一次。返回 null(队列为空)时本函数也回 null——不造一个「什么都没有的面板」。
* @return 面板该拿着的那一份:手上原有的**原样**返回(连实例都不换——`ShareConfirmPanel` 用它做
* `remember` 的键,换一份等值的新对象会让预览白重建一次);手上没有时就是刚认领的那一条。
*/
internal fun shareConfirmClaim(
holding: ShareConfirmDraft?,
take: () -> SharedContent?,
): ShareConfirmDraft? = holding ?: take()?.let { ShareConfirmDraft(share = it) }
@@ -15,11 +15,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.domain.chat.DocumentChips
@@ -247,15 +251,15 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
* 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、
* 输入框与按钮原地不动。
*
* ## 正文为什么只给三行
* ## 正文给三行,但三行之外是「滚」不是「省略号」
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
* 就成了一个阅读器。三行之外用省略号收口,不假装那是全文。
* 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短
* 变化。
*
* ⚠️ W53X3 之后**面板里再没有第二个地方放着这段正文**:输入框已经改成「用户附加的描述」(初值恒为
* 空),不再是正文的可编辑副本。也就是说超过三行的分享正文在面板上只看得到开头——**这不影响发出去的
* 内容**([shareConfirmBody] 用的是完整的那一段,一字不丢),但它确实少了一处「发送前读全文」的地方。
* 这是 W53X 那一版的老行为,本批只把它如实写下来、**没有顺手改**:正文块要不要能展开属于观感取舍,
* 得由主人拍板。
* 但三行之外**不再收成省略号**(W54D 主人拍板改掉了 W53X 的老行为):W53X3 之后**面板里再没有第二
* 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断
* 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗
* 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。
*/
@Composable
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
@@ -303,22 +307,70 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Text(
text = preview.text,
style = MiuixTheme.textStyles.footnote1,
maxLines = SHARE_PREVIEW_TEXT_LINES,
overflow = TextOverflow.Ellipsis,
)
ShareConfirmBody(preview.text)
}
}
}
}
/**
* 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews])。
*
* ## 为什么量行高,而不是写一个 dp 常量
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
* 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。
*
* 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
* 与样式都没变,行位置与首帧一致,量出来的值也一样。
*
* ⚠️ 代价是**首帧先按完整正文排一次**、下一帧才收到三行:正文很长时,这一窗理论上会有一帧高出一截、
* 然后回落。另一条路——写死一个 dp 上限——没有这一帧,但在非默认字号档上必然切字,两害相权取了
* 「量」。这一帧在实际渲染里看不看得见(弹层这时正在进场,可能被动画盖住)**没有在真机上验过**,
* 留给主人真机验收。
*
* ## 两层滚动怎么分工
* 这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块合起来的高度
* 上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来就走嵌套滚动,
* 不用额外接线。
*/
@Composable
private fun ShareConfirmBody(text: String) {
val density = LocalDensity.current
var cap by remember(text) { mutableStateOf(Dp.Infinity) }
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = cap)
.verticalScroll(rememberScrollState()),
) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
onTextLayout = { layout ->
if (cap == Dp.Infinity && layout.lineCount > 0) {
// 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
}
},
)
}
}
/** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
/** 预览里正文给几行(超出的部分收成省略号,见 [ShareConfirmPreviews] 的 KDoc)。 */
private const val SHARE_PREVIEW_TEXT_LINES = 3
/**
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
*
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
*/
internal const val SHARE_PREVIEW_TEXT_LINES = 3
/**
* 分享进来的一份文件 ⇒ 待发附件。
@@ -20,7 +20,7 @@ enum class ShareLanding {
*/
TellOffline,
/** 连接就绪:取走一条并交给既有发送路径。 */
/** 连接就绪:取走一条并交给确认面板(点「发送」才真的发出去)。 */
Send,
}
@@ -41,9 +41,9 @@ enum class ShareLanding {
*
* ## 判据与优先级
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后面每一条——连接一到就该发,
* 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与
* 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并交给确认面板(点「发送」才真的发)。
* 它**压过**后面每一条——连接一到就该认领,哪怕此前已经等够久、那句「等连上会自动发」也说过了。
* 返回值是**单值动作**,所以「认领」与「告知」在类型上就不可能同时发生(不会既取了又报怨还没连上)。
* 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说
* (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。
* 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档
@@ -1,6 +1,7 @@
package top.yeij.cyrene.core.notify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
@@ -18,7 +19,7 @@ class ProactiveNotifierTest {
private val presence = AppPresence()
private val posted = mutableListOf<String>()
private val notifier = ProactiveNotifier(presence = presence, post = { posted += it })
private val notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text })
/** 一个「Activity 实例」(身份即语义,见 [AppPresence])。 */
private val screen = Any()
@@ -61,6 +62,30 @@ class ProactiveNotifierTest {
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `each message gets its own notification id while a replay does not`() {
// 「一事一条」在这一层就定好了:号由**去重键**派生,所以「认得出是同一条」与「落在同一条
// 通知上」是同一个判断的两面。第二条消息必须是另一个号——同一个号时 `notify` 是替换,
// 她连说两句用户就只看到最后一句(这就是改前的症状)。
background()
val ids = mutableListOf<Int>()
val recorder = ProactiveNotifier(presence = presence, post = { _, id -> ids += id })
recorder.onProactiveFrame(null, id = "1371", messageId = "im-7", timestamp = null, content = "记得喝水哦")
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再发一条,也就不该多出第二个号。
recorder.onProactiveFrame(
SessionSummaryDto.MAIN_SESSION_ID,
id = "1371",
messageId = null,
timestamp = null,
content = "记得喝水哦",
)
recorder.onProactiveFrame(null, id = "1372", messageId = null, timestamp = null, content = "在忙吗")
assertEquals(2, ids.size)
assertNotEquals("两条不同的消息必须占两条通知", ids[0], ids[1])
}
@Test
fun `a frame without a row id is deduplicated by the im message id`() {
// 落库失败那一档:`id` / `createdAt` 一起缺席,但 IM 侧消息号还在——服务端重推同一帧
@@ -2,6 +2,7 @@ package top.yeij.cyrene.core.notify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -106,10 +107,27 @@ class ProactiveNotificationShapeTest {
@Test
fun `the proactive notification never replaces the keep-alive one`() {
assertTrue(
"同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉",
PROACTIVE_NOTIFICATION_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID,
// 同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉。
// 号现在按消息派生(见 `proactiveNotificationId`),所以这里钉的是「派生的号落在保活那条
// 之外」——扫一遍真实形状的键(服务端行 id、IM 消息号、时间字面量、正文兜底、空键)。
val keys = listOf(
"0",
"1",
"2",
"1371",
"im-7",
"4294967295",
"2026-10-06T01:20:05.123+08:00",
"记得喝水哦",
"",
)
keys.forEach { key ->
assertTrue(
"键「$key」派生出的号撞上了保活那条常驻通知",
proactiveNotificationId(key) != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID,
)
}
}
@Test
@@ -132,3 +150,50 @@ class ProactiveNotificationShapeTest {
assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE)
}
}
/**
* 通知 id 的派生(W54A)——「一事一条」靠的就是它。
*
* 三条各自钉住一件**错了就肉眼可见**的事:
* 1. **同一条消息派生两次必须同号**:重放、或实时帧与落库帧两条路都到,落的是同一条通知;
* 2. **两条不同的消息必须不同号**:同号时 `notify` 是替换,她连说两句就只剩最后一句(改前的症状);
* 3. **任何输入都得落在合法区间**:Android 的通知 id 是 int,而 `hashCode()` 可以为负、甚至正好是
* `Int.MIN_VALUE`——那一个连 `abs` 都兜不住(`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)。
*/
class ProactiveNotificationIdTest {
@Test
fun `the same message derives the same id every time`() {
// 幂等:进程重启、两条路先后到达都不该改变「这条消息是哪一条通知」。
val key = proactiveNotifyKey("1371", "im-7", null, "记得喝水哦")
assertEquals(proactiveNotificationId(key), proactiveNotificationId(key))
// 键里那三个 id 的**优先级**也是同一件事的一半:同一条消息两条路给的是不同的入参形状,
// 落到同一个键上才算真的稳。
assertEquals(
key,
proactiveNotifyKey("1371", null, null, "记得喝水哦"),
)
}
@Test
fun `two different messages derive two different ids`() {
val first = proactiveNotificationId(proactiveNotifyKey("1371", "im-7", null, "记得喝水哦"))
val second = proactiveNotificationId(proactiveNotifyKey("1372", "im-8", null, "在忙吗"))
assertNotEquals("不同号是「先来的不被后来的顶掉」的全部前提", first, second)
}
@Test
fun `every derived id stays positive even when the hash is Int MIN_VALUE`() {
// 先钉住这条用例的前提:这个字符串的 hashCode **正好是** Int.MIN_VALUE(`abs` 兜不住的那个
// 值)。前提没了就等于这条用例什么都没验。
val extreme = "polygenelubricants"
assertEquals(Int.MIN_VALUE, extreme.hashCode())
val keys = listOf("", "0", "1371", "web", "她主动说的话", extreme)
keys.forEach { key ->
assertTrue("键「$key」派生出了非正号:${proactiveNotificationId(key)}", proactiveNotificationId(key) > 0)
}
}
}
@@ -245,6 +245,109 @@ class ChatContentParserTest {
assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
}
// ------------------------------------------------------------------
// 落单标签容错:宁可剥掉标签,也绝不把标签当正文
//
// 畸形 markup 来自服务端(客户端只做容错,不代表服务端这样发是对的)。
// 这些断言是上面「畸形 / 未闭合」那组的另一面,两边必须同时成立。
// ------------------------------------------------------------------
/** 线上实测的一条畸形消息:第三段多一个 `<p>`,只有一个 `</p>`。 */
private val malformedRealMessage =
"<p><action>满意地点点头</action></p>\n" +
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
"<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
@Test
fun realMalformedMessageRendersCleanBubbles() {
val bubbles = parse(malformedRealMessage)
assertEquals(
listOf(
"真乖♪ 这可是今天最让人家放心的一句。",
"那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?",
),
bubbles.map { it.body },
)
// 纯旁白块照既有规则依附到下一个有正文的气泡上(head)。
assertEquals(listOf(ContentPart.Action("满意地点点头")), bubbles[0].head)
assertNoLooseTagInRenderedText(bubbles)
}
@Test
fun duplicatedOpeningTagIsDropped() {
assertEquals(listOf("正文"), parse("<p><p>正文</p>").map { it.body })
}
@Test
fun strayClosingTagIsDropped() {
assertEquals(listOf("正文"), parse("<p>正文</p></p>").map { it.body })
}
@Test
fun duplicatedOpeningTagInLaterBlockKeepsBubbleSplit() {
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p><p>乙</p>").map { it.body })
}
@Test
fun duplicatedOpeningTagKeepsTheNoteAttachedToTheNextParagraph() {
val bubble = parse("<p><action>点头</action></p><p><p>乙</p>").single()
assertEquals("乙", bubble.body)
assertEquals(listOf(ContentPart.Action("点头")), bubble.head)
}
@Test
fun duplicatedOpeningTagWithAttributesIsDropped() {
assertEquals(listOf("正文"), parse("<p><p class=\"line\">正文</p>").map { it.body })
}
@Test
fun looseTagsSurroundedByWhitespaceAreDropped() {
assertEquals(listOf("正文"), parse(" <p>\n<p>正文</p>\n</p> ").map { it.body })
}
@Test
fun leadingAndTrailingLooseTagsLeaveNoEmptyBubble() {
assertEquals(listOf("甲"), parse("</p><p>甲</p></p>").map { it.body })
}
@Test
fun loneParagraphTagsProduceNoBubble() {
assertTrue(parse("<p>").isEmpty())
assertTrue(parse("</p>").isEmpty())
assertTrue(parse(" <p> ").isEmpty())
assertTrue(parse("<p></p></p>").isEmpty())
assertTrue(parse("<p><p>").isEmpty())
}
@Test
fun unclosedTagAfterACompleteBlockIsDroppedNotShown() {
// 段落结构已被前一个完整块定死,尾部这个没闭合的 `<p>` 只可能是标记。
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
}
@Test
fun strayRoleTagOutsideParagraphIsDropped() {
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
assertEquals(listOf("甲"), parse("</action><p>甲</p>").map { it.body })
}
@Test
fun plainAngleBracketsAreNeverEaten() {
assertEquals(listOf("1 < 2 且 3 > 2"), parse("1 < 2 且 3 > 2").map { it.body })
assertEquals(listOf("<3 是想你的意思"), parse("<3 是想你的意思").map { it.body })
assertEquals(listOf("a<b>c"), parse("a<b>c").map { it.body })
assertEquals(listOf("<div>这是一段 HTML</div>"), parse("<div>这是一段 HTML</div>").map { it.body })
assertEquals(listOf("<p2>这不关 p 标签的事</p2>"), parse("<p2>这不关 p 标签的事</p2>").map { it.body })
assertEquals(listOf("<P>大小写不认</P>"), parse("<P>大小写不认</P>").map { it.body })
assertEquals(listOf("你好<p"), parse("你好<p").map { it.body })
}
@Test
fun plainAngleBracketsInsideAParagraphAreNeverEaten() {
assertEquals(listOf("甲 1 < 2", "乙"), parse("<p>甲 1 < 2</p><p>乙</p>").map { it.body })
assertEquals(listOf("甲<p2>乙"), parse("<p>甲<p2>乙</p>").map { it.body })
}
// ------------------------------------------------------------------
// 流式增量:最终结果必须与一次性解析一致
// ------------------------------------------------------------------
@@ -257,6 +360,7 @@ class ChatContentParserTest {
<p>你好*弯起眼睛*</p>
<p>这是*强调*文字</p>
<p>*旁白:其实偷偷开心了一下*</p>
<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>
<p>晚安。</p>
""".trimIndent()
@@ -315,6 +419,63 @@ class ChatContentParserTest {
assertEquals(listOf("第一", "第二"), second.map { it.body })
}
/** 逐字符喂实测畸形消息:中间态任何一帧都不许把标签当正文吐上屏。 */
@Test
fun streamingRealMalformedMessageNeverLeaksTheTag() {
val parser = StreamingContentParser()
for (ch in malformedRealMessage) {
assertNoLooseTagInRenderedText(parser.onDelta(ch.toString()))
}
assertEquals(parse(malformedRealMessage), parser.finish())
}
/** 中间态只许「加」,不许改口:已经显示过的正文不能因为后续 delta 变样(那才是跳动)。 */
@Test
fun streamingRealMalformedMessageOnlyGrowsWhatItAlreadyShowed() {
val parser = StreamingContentParser()
var shown = emptyList<String>()
for (ch in malformedRealMessage) {
val bodies = parser.onDelta(ch.toString()).map { it.body }
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
shown.forEachIndexed { i, body ->
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
}
shown = bodies
}
}
@Test
fun streamingRandomChunksOfMalformedMessageMatchOneShot() {
val expected = parse(malformedRealMessage)
val random = Random(20261006)
repeat(25) {
val parser = StreamingContentParser()
var index = 0
while (index < malformedRealMessage.length) {
val size = 1 + random.nextInt(7)
val end = minOf(index + size, malformedRealMessage.length)
parser.onDelta(malformedRealMessage.substring(index, end))
index = end
}
assertEquals(expected, parser.finish())
}
}
/**
* 重复开标签必须连同它后面的正文一起被扣留到收口。
*
* 放宽扣留(把重复的 `<p>` 当「外层冗余」、让前缀继续推进)会把 `甲` 先吐上屏,
* 等 `</p>` 到了再变成 `甲乙`——用户看到的字会跳,所以这里钉住「中间态一帧都不吐」。
*/
@Test
fun streamingWithholdsDuplicateOpeningTagUntilItsPartnerArrives() {
val parser = StreamingContentParser()
for (chunk in listOf("<p>甲", "<p>乙", "</p>")) {
assertNoLooseTagInRenderedText(parser.onDelta(chunk))
}
assertEquals(listOf("甲乙"), parser.finish().map { it.body })
}
@Test
fun streamingUnclosedActionFallsBackOnFinish() {
val parser = StreamingContentParser()
@@ -347,4 +508,13 @@ class ChatContentParserTest {
assertTrue(parser.onDelta("").isEmpty())
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
}
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
for (bubble in bubbles) {
for (part in bubble.parts) {
assertFalse("标签漏进了正文:${part.text}", part.text.contains("<p") || part.text.contains("</p"))
}
}
}
}
@@ -0,0 +1,107 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.share.SharedContent
/**
* 「一份已经取走的分享怎么活过重建」([shareConfirmClaim])。
*
* 背景:分享进来的内容先弹确认面板(主人 2026-10-06 拍板:像其他 IM 一样先确认再发),而它被
* `ShareIntake.take()` **取走**之后就没有归还路径了;此前那份内容与用户正在输入的那句描述只挂在
* `ChatScreen` 的一格 `remember(sessionId)` 上——Activity 因转屏 / 深浅色切换重建时组合整份丢掉,
* 那份分享既不在待发队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。
*
* 本测试钉住的是**判据**(它此后活在 `ChatUiState.shareConfirm` 里,由 VM 经
* `ChatViewModel.claimSharedContent` 调):
*
* 1. **手上已经有东西时,队列一个字节都不动**——重建之后新的一帧会再问一次同样的问题,而那时
* 手上已经有那份内容 ⇒ [shareConfirmClaim] **不会**调 `take()`。这条是「同一份分享只被取走一次」
* 的落点;少了它,第二次取走拿到的要么是 null(白跑),要么是**队列里的下一条**——后者会把用户
* 正看着的那份顶掉,那是一次静默丢失。
* 2. **内容与描述都原样还在**(描述与正文绑在同一个值里,两者同生共死)。
* 3. **手上没有时才认领,且只认领一次**;队列为空时什么都不发生(不造一个「什么都没有的面板」,
* 也不会把「空手」记成「已经取到了」——下一帧队列里有东西时照旧认领得到)。
*
* ## 这里怎么模拟「重建」
* 单测里没有 Activity,也没有组合。这里的做法是把 [ShareConfirmDraft] 当成**那一格不会随组合消失
* 的值**(它在真机上就是 `ChatUiState.shareConfirm`),然后拿 `take()` 的调用次数当探针:转屏前后
* 是**同一个值**、而队列是**同一个队列**(第二次取走一定会取出「后来又分享的那一条」)。真机上
* 「面板转屏后照旧长出来」这一步由主人验收——那是 Compose 的观感,JVM 单测碰不到。
*/
class ShareConfirmDraftTest {
@Test
fun `a rebuilt screen still holds the same share and caption, and never takes twice`() {
val first = SharedContent(text = "https://example.com/某个页面?id=1")
val later = SharedContent(text = "后来又分享的一条")
val queue = ArrayDeque(listOf(first, later))
var takeCalls = 0
fun take(): SharedContent? {
takeCalls++
return queue.removeFirstOrNull()
}
// 第一帧:手上空空 ⇒ 认领队首那一条,描述从空开始(预填一次会把同一句话在面板里排两遍)。
val claimed = shareConfirmClaim(holding = null, take = ::take)
assertEquals(1, takeCalls)
assertSame("认领的必须是队列里那条本身", first, claimed?.share)
assertEquals("描述初值恒为空", "", claimed?.caption)
// 用户补了一句描述(它跟着同一格走)。
var holding = requireNotNull(claimed).copy(caption = "这句话不错")
// 转屏:旧的那次组合整份丢掉,新的一帧拿着**同一格**再走到判据这里——走两遍,因为
// effect 的键里有连接状态,重建之后它会不止重跑一次。
repeat(2) {
val next = shareConfirmClaim(holding = holding, take = ::take)
assertSame("重建之后内容必须还是那一份", holding.share, next?.share)
assertEquals("重建之后用户输入到一半的描述也不能丢", "这句话不错", next?.caption)
holding = requireNotNull(next)
}
// 关键的一条:`take()` 只在第一帧跑过那一次。第二次跑起来会取出「后来又分享的一条」——
// 那正是「用户正看着 A、面板里的内容却被换成了 B」的来处。
assertEquals("手上已经有东西时绝不再取一次", 1, takeCalls)
assertEquals("队列里那一条也不许被白白取走", listOf(later), queue.toList())
}
@Test
fun `an empty queue claims nothing, and the next frame still can`() {
val queue = ArrayDeque<SharedContent>()
var takeCalls = 0
fun take(): SharedContent? {
takeCalls += 1
return queue.removeFirstOrNull()
}
// 队列为空:什么都不发生——不造一个空的「面板」,也不把「空手」记成「已经取到了」。
assertNull(shareConfirmClaim(holding = null, take = ::take))
assertEquals(1, takeCalls)
// 下一帧队列里有了东西:照旧认领得到(这一档正是「面板开着时新来的分享原地在队列里等,
// 等这一份确认完再接上」)。
queue += SharedContent(text = "等面板关掉才轮到我")
val claimed = shareConfirmClaim(holding = null, take = ::take)
assertEquals("等面板关掉才轮到我", claimed?.share?.text)
assertEquals(2, takeCalls)
}
@Test
fun `a share that could not be read is handed over as it is`() {
// 认领只做出队这一件事:内容什么样都**原样**交给面板。`contentUnreadable` 那条分享(对方给了
// 东西、本端连地址都没认下来)照样要弹面板——它的预览是空的,但「要不要说一句、说什么」由
// `shareNoticeFor` 与面板自己负责,判据不该在这里替它筛一遍(筛掉就成了一次静默丢弃)。
val unreadable = SharedContent(contentUnreadable = true)
val claimed = shareConfirmClaim(holding = null) { unreadable }
assertSame(unreadable, claimed?.share)
assertTrue(
"原样交出去,丢件 / 读不到这些账一个字段都不动",
requireNotNull(claimed).share.contentUnreadable,
)
}
}
@@ -32,6 +32,13 @@ import top.yeij.cyrene.domain.share.SharedDocument
* ⚠️ 「输入框初值为空」本身是 Compose 状态(`ChatScreen` 里那格 `remember`),单测碰不到——它由
* 主人真机验收(输入框空着、预览里有正文)。这份测试能钉住的是它的**后果**:描述为空时,拼出来的
* 正文就是分享正文本身、而且只出现**一次**(见 `the caption is not a copy of the shared text`)。
*
* ## W54D 又改掉了什么
* 正文那一窗从「三行 + 省略号」改成「三行窗口 + 内部滚动」(主人拍板):面板里再没有第二处放着这段
* 正文,收成省略号就等于「按发送之前读不到要发的那段正文」。滚动是渲染层的事、单测够不着(本模块
* 没有 Compose UI 测试的依赖),所以这里钉的是它的两个前提——**正文完整地进到预览**(一个字都不少)
* 与**这一窗仍然是三行**(面板不随正文变高),见 `the whole body reaches the preview …` 与
* `the preview window stays three lines tall`。
*/
class ShareConfirmTest {
@@ -54,6 +61,33 @@ class ShareConfirmTest {
assertFalse(preview.isEmpty)
}
@Test
fun `the whole body reaches the preview even when it is longer than the window`() {
val long = (1..40).joinToString("\n") { "第 $it 行" }
// W54D 主人拍板:预览的正文那一窗恒为三行高,但**里面能滚**——三行之外不再收成省略号。这条钉
// 的是那一窗的输入:纯函数层把**完整**的正文交给渲染层(这一段一个字都不许在这里少),少了就
// 一定读不到——面板里再没有第二处放着这段正文(输入框只剩「用户附加的描述」)。
val preview = shareConfirmPreview(SharedContent(text = long))
assertEquals(long, preview.text)
assertEquals(40, preview.text.lines().size)
assertTrue(
"这一档必须真的超过三行,否则上面那条断言什么也证明不了",
preview.text.lines().size > SHARE_PREVIEW_TEXT_LINES,
)
// 发出去的那一段同样一个字不少(两边同源,见 [shareConfirmBody])。
assertEquals(long, shareConfirmBody(SharedContent(text = long), ""))
}
@Test
fun `the preview window stays three lines tall`() {
// 3 这个字面量就是这条测试的全部内容:滚动是渲染层的事、单测够不着(本模块没有 Compose UI
// 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。改大 ⇒ 面板会随长正文变高(主人
// 拍板的高度上限没了);改小 ⇒ 正文的可见窗口白缩一行。
assertEquals(3, SHARE_PREVIEW_TEXT_LINES)
}
@Test
fun `blank pieces count as nothing to show`() {
val preview = shareConfirmPreview(