merge: W54C —— 转屏不再静默丢掉已取走的分享(面板状态与描述移进 ChatUiState,take 仍只发生一次)

This commit is contained in:
AskaEth committed 2026-10-06 21:26:29 +08:00
commit a861881ec4
6 files changed
+281 -59

No files matched your search

@@ -139,8 +139,12 @@ object ShareIntake {
/** /**
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。 * 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `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() fun take(): SharedContent? = holder.take()
@@ -405,7 +405,7 @@ fun ChatScreen(
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) } ) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事: // 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废, // 挑时机、请 VM 认领一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废,
// 2026-10-06 主人要求像其他 IM 一样先确认——只有点「发送」才真的发)。 // 2026-10-06 主人要求像其他 IM 一样先确认——只有点「发送」才真的发)。
// 判据本身是纯函数 [shareLanding](可单测)。 // 判据本身是纯函数 [shareLanding](可单测)。
// - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上), // - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
@@ -422,11 +422,12 @@ fun ChatScreen(
// 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。 // 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个 // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
// effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。 // effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。
// - **取走只发生一次,而且只发生在面板没开的时候**:取到的那一份存进下面那格 Compose 状态, // - **取走只发生一次,而且只发生在「手上什么都没有」的那一帧**:取到的那一份落进**VM 的状态**
// 面板(`ShareConfirmPanel`)靠它渲染。effect 的键里有连接状态 ⇒ 它会反复重跑,而重跑 // ([ChatUiState.shareConfirm],不是这里的一格 `remember`),面板靠它渲染。effect 的键里有连接
// **绝不会**再取一份、也绝不会重复发送:`take()` 只在 `shareConfirm == null` 时被调用一次, // 状态 ⇒ 它会反复重跑,而重跑**绝不会**再取一份、也绝不会重复发送:闸门在 `take()` **之前**,
// 取走之后队列已空(判据看到的是 `Nothing`),而面板开着时新来的分享**原地在队列里等**—— // 而闸门的依据就是 VM 里那一格(`ChatViewModel.claimSharedContent` 的 KDoc 写着它为什么在
// 键里的 `shareConfirm == null` 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。 // `take()` 之前)。面板开着时新来的分享**原地在队列里等**——键里的 `shareConfirm == null`
// 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。
val pendingShares by ShareIntake.pending.collectAsState() val pendingShares by ShareIntake.pending.collectAsState()
val shareConnection = state.connection val shareConnection = state.connection
// 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走: // 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走:
@@ -436,17 +437,13 @@ fun ChatScreen(
// 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑) // 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑)
// 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。 // 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。
var shareToldWaiting by remember(sessionId) { mutableStateOf(false) } var shareToldWaiting by remember(sessionId) { mutableStateOf(false) }
// 「这一份分享已经取走、正在等用户确认」。它此前**没有地方待**——取到就直接送进发送链了; // 「一份分享已经取走、正在等用户确认」+ 用户补的那句描述。**它们刻意不在 Compose 里**:那一份
// 现在取到的那一份停在这格 Compose 状态里,确认面板靠它渲染(`ShareConfirmPanel`)。 // 已经出队、没有归还路径,而这一格 `remember` 会随转屏 / 深浅色切换整份丢掉——丢掉的后果是
// // 那份分享既不在队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。
// 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着, // 所以它住在 VM 的状态里(面板输入框那句描述与正文绑在同一个值里,见 [ShareConfirmDraft]):
// 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。 // `ChatSessionSlots` 的那一格 `ViewModelStore` 由 `AppContainer` 持有,比 Activity 活得久。
var shareConfirm by remember(sessionId) { mutableStateOf<SharedContent?>(null) } // ⚠️ 别把它挪回这里——「比 Activity 活得久」正是它存在的全部理由。
// 面板输入框里的文字 = **用户附加的描述**(W53X3 更正:它不再是分享正文的复刻,初值恒为空)。 val shareConfirm = state.shareConfirm
// 它与上面那份内容**分开存**是刻意的:预览那一份说的是「分享进来的是什么」,这格说的是
// 「用户想补一句什么」,两者本来就是两件事,合成一个字段会让预览跟着输入框一起变。
// 真正发出去的正文由纯函数 [shareConfirmBody] 在按下「发送」那一刻拼(分享正文 + 这段描述)。
var shareCaption by remember(sessionId) { mutableStateOf("") }
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) { LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) { if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
shareWaitedLong = false shareWaitedLong = false
@@ -469,19 +466,14 @@ fun ChatScreen(
when (landing) { when (landing) {
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。 // 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
// //
// **取走只在这里、且只在面板没开的时候**:`shareConfirm == null` 既是这条闸门,也是本 // **取走只在这一处发生**,而且闸门在 `take()` **之前**(判据是纯函数 [shareConfirmClaim],
// effect 的键之一。面板开着时来的新分享一个字节都不动(原地留在待发队列里,等这一份 // 落在 `ChatViewModel.claimSharedContent` 里):手上已经有了那份未确认的分享时,这一帧
// 确认完再接)——那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。 // 一个字节都不动队列——面板开着时来的新分享原地留在待发队列里,等这一份确认完再接,
// 那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。转屏重建之后新的一帧照样走到
// 这里,而那时**手上已经有东西**(它活在 VM 的状态里),所以也不会再取一次。
ShareLanding.Send -> { ShareLanding.Send -> {
shareToldWaiting = false shareToldWaiting = false
if (shareConfirm == null) { viewModel.claimSharedContent()
ShareIntake.take()?.let { share ->
shareConfirm = share
// 描述从空开始:它是用户要附加的话,**不是**这次分享进来的正文——
// 预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。
shareCaption = ""
}
}
} }
// 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定, // 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定,
@@ -824,34 +816,19 @@ fun ChatScreen(
// 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还 // 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还
// 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与 // 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与
// 描述都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。 // 描述都在 **VM 的状态**里(`state.shareConfirm`,理由见那里与 `ChatViewModel.claimSharedContent`
// 的 KDoc),面板本身与它的判据都在 `ShareConfirmPanel.kt`。转屏重建之后面板照旧在这里长出来,
// 因为 VM 比 Activity 活得久;**它绝不能被挪回这个组合里**。
// //
// ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**: // ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**:
// 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。 // 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。
shareConfirm?.let { share -> shareConfirm?.let { draft ->
ShareConfirmPanel( ShareConfirmPanel(
share = share, share = draft.share,
caption = shareCaption, caption = draft.caption,
onCaptionChange = { shareCaption = it }, onCaptionChange = viewModel::onShareCaptionChange,
onSend = { onSend = viewModel::sendShareConfirm,
// 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。 onCancel = viewModel::cancelShareConfirm,
//
// 正文 = 分享自带的那一段 + 用户补的那一句,由纯函数 [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 = ""
},
) )
} }
} }
@@ -633,6 +633,15 @@ data class ChatUiState(
* 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生(或刚刚等着)的一次动作。 * 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生(或刚刚等着)的一次动作。
*/ */
val shareNotice: String? = null, val shareNotice: String? = null,
/**
* 面板手上正拿着的那份分享(**已经出队**)+ 用户补的那句描述;null = 没有面板。
*
* 它**必须**在这里(而不是一格 `remember`):内容出队之后没有归还路径,而 Activity 转屏 /
* 深浅色切换会让组合整份重建——挂在组合上的那一格一丢,那份分享就从世界上消失了(队列里没有、
* 界面上也没有,用户什么都没看到)。理由与「正文 + 描述为什么绑在一个值里」见
* [ShareConfirmDraft] 的 KDoc;认领那一步见 [ChatViewModel.claimSharedContent]。
*/
val shareConfirm: ShareConfirmDraft? = null,
/** /**
* 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。 * 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。
* *
@@ -816,6 +825,11 @@ class ChatViewModel(
* - [ChatUiState.queuedForConnection]:一次都还没上过路、在等连接的那几条(内容挂在 `pending` * - [ChatUiState.queuedForConnection]:一次都还没上过路、在等连接的那几条(内容挂在 `pending`
* 那条气泡上,两者同生共死;这里两样都写,是为了将来只改其中一处时仍然拦得住)。 * 那条气泡上,两者同生共死;这里两样都写,是为了将来只改其中一处时仍然拦得住)。
* *
* 第四个来源 [ChatUiState.shareConfirm] 是**同上**的一格,只是更没有退路:那份分享已经从待发
* 队列里出队(没有归还路径),把这一格交出去等于把它删掉——界面上那个确认面板会跟着这一格一起
* 消失,而它连输入栏都回不去。它同样只在「本端唯一一份」这个意义上算数(用户已经输入到面板里的
* 描述也算,两者本来就绑在同一个值里)。
*
* 流式中的回复([ChatUiState.streams])**刻意不算**:那是服务端正在生成的那一份的本地投影, * 流式中的回复([ChatUiState.streams])**刻意不算**:那是服务端正在生成的那一份的本地投影,
* 本端这一格没了那一轮照旧跑完、照旧落库,重进会话会重新拉到;它也不具备「本端唯一」这条性质 * 本端这一格没了那一轮照旧跑完、照旧落库,重进会话会重新拉到;它也不具备「本端唯一」这条性质
* (而它正是「回收能省下多少」的大头之一,把它算进来等于让回收在这最该省的时候不动手)。 * (而它正是「回收能省下多少」的大头之一,把它算进来等于让回收在这最该省的时候不动手)。
@@ -826,7 +840,8 @@ class ChatViewModel(
it.draftImages.isNotEmpty() || it.draftImages.isNotEmpty() ||
it.draftDocuments.isNotEmpty() || it.draftDocuments.isNotEmpty() ||
it.pending.isNotEmpty() || it.pending.isNotEmpty() ||
it.queuedForConnection.isNotEmpty() it.queuedForConnection.isNotEmpty() ||
it.shareConfirm != null
} }
private var socket: ChatSocket? = 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) }
@@ -20,7 +20,7 @@ enum class ShareLanding {
*/ */
TellOffline, TellOffline,
/** 连接就绪:取走一条并交给既有发送路径。 */ /** 连接就绪:取走一条并交给确认面板(点「发送」才真的发出去)。 */
Send, Send,
} }
@@ -41,9 +41,9 @@ enum class ShareLanding {
* *
* ## 判据与优先级 * ## 判据与优先级
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。 * 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后面每一条——连接一到就该发, * 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并交给确认面板(点「发送」才真的发)。
* 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与 * 它**压过**后面每一条——连接一到就该认领,哪怕此前已经等够久、那句「等连上会自动发」也说过了。
* 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。 * 返回值是**单值动作**,所以「认领」与「告知」在类型上就不可能同时发生(不会既取了又报怨还没连上)。
* 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说 * 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说
* (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。 * (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。
* 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档 * 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档
@@ -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,
)
}
}