feat(share): 分享进来先确认再发 —— 新增确认面板

主人 2026-10-06 拍板:分享进来的内容不再「一落地就发」,要像其他 IM 一样先给一个
确认面板。此前 ChatScreen 的分享落地 effect 取走内容后**直接**调 sendShared,
用户没有任何确认机会;现在它把取走的那一份放进 Compose 状态,面板靠它渲染。

新增 `ui/chat/ShareConfirmPanel.kt`(Miuix 自己的 `WindowDialog`,不是 Material3):
- 预览:图片缩略图(与待发草稿预览同形)+ 文件芯片(显示名 / 体积)+ 分享正文(三行,
  全文在下面那个输入框里,可编辑);
- 输入框:初值就是分享进来的正文本身,**一个字符都不加**(不拼来源标记——那是红线);
- 「取消」= 本次分享作废(不发送、也不塞回待发队列);「发送」才走既有 sendShared。

为什么这么接线(都在 ChatScreen 那一小段里):
- **取走只发生一次**:`ShareIntake.take()` 只在 `shareConfirm == null` 时被调用,而
  `shareConfirm == null` 同时是这个 effect 的键 ⇒ 面板一关就再跑一遍判据、把面板开着时
  攒下的下一条分享接上(不加这个键,那一条会一直躺在队列里等连接状态碰巧再变一次);
- 面板开着时新来的分享**原地留在队列里等**,不覆盖用户正看着的那一份;
- 告知按**真正发出去的那一份**算(`share.copy(text = 输入框内容)`):正文可能刚被用户改过,
  拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符的话。

未动:shareLanding / shareNoticeFor / shareWaitingNotice 的语义,sendShared 里
「超长正文 → 文件」那条裁决,ShareIntake 的提取与暂存,W49/W50/W51 的判据。

判据抽成纯函数并单测(`ShareConfirmTest`,8 条):预览该显示哪几样(图片数 / 文件 / 正文是否
非空)、输入框初值、以及「发送」可点 ≙ 真的会有东西发出去(与 sendShared 开头那条早退同一条)。

验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest → BUILD SUCCESSFUL,
102 suite / 1278 tests / 0 失败(基线 101 / 1270)。
This commit is contained in:
AskaEth committed 2026-10-06 18:53:41 +08:00
1 parent ed0824f335
commit b06688f1db
3 files changed
+492 -4

No files matched your search

@@ -393,7 +393,9 @@ fun ChatScreen(
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给既有发送路径。判据本身是纯函数 [shareLanding](可单测)。
// 挑时机、取走一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废,
// 2026-10-06 主人要求像其他 IM 一样先确认——只有点「发送」才真的发)。
// 判据本身是纯函数 [shareLanding](可单测)。
// - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
// 抢在那之前发只换来一次 `sendChat` 回 false 的「发送失败,请重试」;而 [ShareIntake.take] 是
// 原子出队、没有归还路径 ⇒ 那一次「其实一次都没发出去」的失败会把内容从待发队列里抹掉,
@@ -408,6 +410,11 @@ fun ChatScreen(
// 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
// effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。
// - **取走只发生一次,而且只发生在面板没开的时候**:取到的那一份存进下面那格 Compose 状态,
// 面板(`ShareConfirmPanel`)靠它渲染。effect 的键里有连接状态 ⇒ 它会反复重跑,而重跑
// **绝不会**再取一份、也绝不会重复发送:`take()` 只在 `shareConfirm == null` 时被调用一次,
// 取走之后队列已空(判据看到的是 `Nothing`),而面板开着时新来的分享**原地在队列里等**——
// 键里的 `shareConfirm == null` 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。
val pendingShares by ShareIntake.pending.collectAsState()
val shareConnection = state.connection
// 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走:
@@ -417,6 +424,16 @@ fun ChatScreen(
// 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑)
// 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。
var shareToldWaiting by remember(sessionId) { mutableStateOf(false) }
// 「这一份分享已经取走、正在等用户确认」。它此前**没有地方待**——取到就直接送进发送链了;
// 现在取到的那一份停在这格 Compose 状态里,确认面板靠它渲染(`ShareConfirmPanel`)。
//
// 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着,
// 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。
var shareConfirm by remember(sessionId) { mutableStateOf<SharedContent?>(null) }
// 面板输入框里的文字(初值 = 分享进来的正文本身,见 [shareConfirmDraft])。它与上面那份内容
// **分开存**是刻意的:预览要显示的是「分享进来的是什么」,用户改的是「这次要发出去的正文」,
// 改过之后这两件事就不再相同,合成一个字段会让预览跟着输入框一起变。
var shareDraft by remember(sessionId) { mutableStateOf("") }
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
shareWaitedLong = false
@@ -426,8 +443,10 @@ fun ChatScreen(
shareWaitedLong = true
}
// 这个 effect 的键里有连接状态 ⇒ 连接后来就绪时会重跑,内容因此**一次都不会漏发**(也不需要
// 任何轮询:状态一变就重来一遍判据)。
LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong) {
// 任何轮询:状态一变就重来一遍判据)。键里还有 `shareConfirm == null`——面板一关(发送 / 取消)
// 它就翻回来、这个 effect 随之重跑,把面板开着那段时间里攒下的下一条分享接上(不加这个键,
// 那一条会一直躺在队列里,直到连接状态碰巧再变一次)。
LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong, shareConfirm == null) {
val landing = shareLanding(
hasPending = pendingShares.isNotEmpty(),
connection = shareConnection,
@@ -436,9 +455,18 @@ fun ChatScreen(
)
when (landing) {
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
//
// **取走只在这里、且只在面板没开的时候**:`shareConfirm == null` 既是这条闸门,也是本
// effect 的键之一。面板开着时来的新分享一个字节都不动(原地留在待发队列里,等这一份
// 确认完再接)——那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。
ShareLanding.Send -> {
shareToldWaiting = false
ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) }
if (shareConfirm == null) {
ShareIntake.take()?.let { share ->
shareConfirm = share
shareDraft = shareConfirmDraft(share)
}
}
}
// 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定,
@@ -778,6 +806,35 @@ fun ChatScreen(
)
}
}
// 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还
// 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与
// 草稿都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。
//
// ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**:
// 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。
shareConfirm?.let { share ->
ShareConfirmPanel(
share = share,
draft = shareDraft,
onDraftChange = { shareDraft = it },
onSend = {
// 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。
val sent = share.copy(text = shareDraft)
shareConfirm = null
shareDraft = ""
// 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文可能刚被用户在
// 输入框里改过,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符
// 的话。`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决
// 也一字未动。
viewModel.sendShared(sent, shareNoticeFor(sent))
},
onCancel = {
shareConfirm = null
shareDraft = ""
},
)
}
}
/**
@@ -0,0 +1,283 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.remember
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.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.window.WindowDialog
/**
* 「这次分享进来的内容」的确认面板(主人 2026-10-06 拍板:**像其他 IM 一样,先确认再发**)。
*
* ## 它补上的那一步
* 在这之前,分享进来的内容一到连接就绪就被 `ChatViewModel.sendShared` 直接发了出去
* (见 `ChatScreen` 里那段落地的 effect),用户**没有任何确认机会**:一张图、一份文件、
* 一段从浏览器带来的链接,点完分享就已经在对话里了。面板把这一步补上——**只有点「发送」才真的发**,
* 「取消」则本次分享作废(不发送、也不塞回待发队列:那会让「用户刚刚作废的那一份」与「又来了
* 新分享」混成同一件事,见 [top.yeij.cyrene.domain.share.PendingShareHolder])。
*
* ## 取走只发生一次,面板只负责显示
* 这份 [share] **已经**从待发队列里被 `ShareIntake.take()` 取走了(原子出队、没有归还路径),
* 它现在活在 `ChatScreen` 的 Compose 状态里。所以本文件里的东西全是**无状态**的:面板不取内容、
* 不记账、也不判断「该不该弹」——那些判据与记账都在调用方,这里只把拿到的那一份画出来。
*
* ## 为什么用 Miuix 的 `WindowDialog`
* 项目里其余四个弹层(编辑 / 删除 / 附件预览 / 图片预览)用的是 Compose 的 `androidx.compose.ui.window.Dialog`
* + Miuix 内容,因为它当年在 Miuix 里找不到可用的弹层控件。Miuix 0.9.3 其实自带两族:
* `top.yukonga.miuix.kmp.overlay.*`(`OverlayDialog` / `OverlayBottomSheet`,在当前组合里当一层浮层)
* 与 `top.yukonga.miuix.kmp.window.*`(`WindowDialog` / `WindowBottomSheet`,真正的弹窗)。
* 本面板用 **window 族**:它与既有那四个弹层是**同一个形态**——独立窗口、自带遮罩、点面板外或按返回键
* 即取消(`onDismissRequest`,这里接的就是「取消」)。overlay 族要求调用方自己在组合里安排浮层的宿主
* (它的 `renderInRootScaffold` 就是为此而生),那会给「分享落地」这段再加一处 DOM 之外的状态,
* 而这里要的只是一次确认,不需要底部面板那种可拖拽的手势。
*
* ## 预览与输入框的分工
* 上半是**预览**(图片缩略图 / 文件芯片 / 分享正文),下半是**输入框**(初值就是分享进来的正文,
* 见 [shareConfirmDraft])与「取消 / 发送」。正文在预览里**与**输入框里各出现一次是刻意的:
* 预览里那一份是「分享进来的是什么」,用户改了输入框之后它还在(否则改完就再也看不出原文了)。
* 输入框里那一份才是**真正会发出去**的那段文字(`share.copy(text = 输入框内容)`)。
*/
@Composable
fun ShareConfirmPanel(
share: SharedContent,
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
onCancel: () -> Unit,
) {
val preview = remember(share) { shareConfirmPreview(share) }
// `show = true` 恒成立:这个面板只在有内容可确认时被调用方组合出来(`shareConfirm?.let { … }`),
// 关掉它靠的是调用方把那格状态清空(取消 / 发送后的清理都在那边)。
WindowDialog(
show = true,
title = SHARE_CONFIRM_TITLE,
onDismissRequest = onCancel,
) {
Column(modifier = Modifier.fillMaxWidth()) {
if (!preview.isEmpty) {
ShareConfirmPreviews(preview)
Spacer(Modifier.height(12.dp))
}
TextField(
value = draft,
onValueChange = onDraftChange,
modifier = Modifier.fillMaxWidth(),
label = SHARE_CONFIRM_DRAFT_LABEL,
useLabelAsPlaceholder = true,
maxLines = 4,
)
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// 「取消」放在「发送」之前(与编辑 / 删除那两个弹层同一个排法):右端永远是那枚
// **会真的改动东西**的键。
TextButton("取消", onClick = onCancel)
Spacer(Modifier.width(8.dp))
TextButton(
text = "发送",
onClick = onSend,
// 什么都没有可发时这枚键不该可点:`sendShared` 在这种输入下会**原地返回**
// (一次发送都不会发生),可点的键会让人以为按下去有用。判据是纯函数
// [shareConfirmCanSend],与那条早退**同一条**。
enabled = shareConfirmCanSend(share, draft),
)
}
}
}
}
/**
* 这次分享**该展示成哪几样东西**(纯数据,UI 照着渲染)。
*
* 与 [SharedContent] 不是同一个东西:[SharedContent] 是「用户分享了哪些字段」(还带着丢件计数、
* 超限计数这些**告知**用的账),本类型只保留**看得见的那几样**。混用一个类型的话,渲染层迟早会去
* 读那些计数字段并顺手显示出来,而它们该说的话由 `shareNoticeFor` 统一成句(它是唯一一处出处)。
*/
data class ShareConfirmPreview(
/** 要显示的图片(本端 cache 里的 `file://` 地址,顺序即发送顺序)。 */
val images: List<String>,
/** 要显示的文件(显示名 + 体积由既有的 `DocumentChipRow` 负责换算)。 */
val documents: List<SharedDocument>,
/** 要显示的正文;空白已归一成空串。 */
val text: String,
) {
/** 这份分享带不带正文(空串与「全空白」都算不带——只显示一行空气不是预览)。 */
val hasText: Boolean get() = text.isNotEmpty()
/** 三样都没有 = 这次分享**没有可预览的内容**(此时预览区整块不出现)。 */
val isEmpty: Boolean get() = images.isEmpty() && documents.isEmpty() && !hasText
}
/**
* 把一份分享折算成要显示的那几样(**纯函数**,可单测)。
*
* 两处过滤是**空的也算没有**:一条空地址画出来就是一个空白缩略图(用户会以为图坏了),
* 一段全空白的正文占着一行什么也不说。正文两侧的空白一并去掉,与
* [top.yeij.cyrene.domain.share.ShareRules.extract] 取正文那一处同一个口径。
*
* 顺序原样保留:多张图的顺序**就是发送顺序**(第一条消息带第一张,见 `imageSendPlan`),
* 预览里换一个顺序就是在替用户改他要发的东西。
*/
fun shareConfirmPreview(share: SharedContent): ShareConfirmPreview = ShareConfirmPreview(
images = share.images.filter(String::isNotBlank),
documents = share.documents.filter { it.uri.isNotBlank() },
text = share.text.trim(),
)
/**
* 确认面板里那个输入框的**初值**(**纯函数**,可单测):就是分享进来的正文本身。
*
* 分享进来的东西**一个字都不许丢**:从浏览器分享一个链接进来时,`EXTRA_TEXT` 里那段地址就是
* 用户想发的东西,把它留在别处(或者干脆丢掉、让用户自己再打一遍)都是在替用户删掉他要发的内容。
*
* 而它也**不许加任何东西**——不拼「[用户分享]」之类的来源标记、不加引号、不套模板。来源标识走
* 后端的独立 `RoleSystem` 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的
* 标记只能放在本端渲染层;把标记拼进正文 = 让用户正文冒充系统指令,那是项目红线。所以这里
* 与 [shareConfirmPreview] 的 `text` 是**同一个值**,两者不可能分叉。
*/
fun shareConfirmDraft(share: SharedContent): String = shareConfirmPreview(share).text
/**
* 面板里那枚「发送」该不该可点(**纯函数**,可单测)。
*
* 判据与 `ChatViewModel.sendShared` 开头那条早退**是同一条**:正文(去掉空白后)为空、图片为空、
* 文件也为空时,那个方法会原地 `return`——一次发送都不会发生。此时把键画成可点的,用户按下去只会
* 看到面板关掉、什么也没发生,而他会以为自己少发了一条。所以这里回 `false`。
*
* ⚠️ 三样里**任意一样非空就可发**:纯图片 / 纯文件的回合服务端**支持**(契约:`text` / `image` /
* `documents` 任一项非空即可),本端也照旧只发得出去的那几样,不因为「没写文字」而拦下它。
*/
fun shareConfirmCanSend(share: SharedContent, draft: String): Boolean {
val preview = shareConfirmPreview(share)
return draft.isNotBlank() || preview.images.isNotEmpty() || preview.documents.isNotEmpty()
}
/** 面板标题(与顶栏那句「与昔涟」同一称呼)。 */
const val SHARE_CONFIRM_TITLE = "发送给昔涟"
/**
* 输入框的标签(`useLabelAsPlaceholder`:**只在框里没字时**显示)。
*
* 分享带正文时框里已经有字、这句标签看不见,但那正是用户能改的那段文字(它是一个可编辑的输入框,
* 不是一段只读的展示):改它等于改这次要发出的正文。
*/
const val SHARE_CONFIRM_DRAFT_LABEL = "附一句话(可以修改)"
/**
* 预览区(图片 / 文件 / 正文)。
*
* ## 高度上限与内部滚动
* Miuix 那两族弹层的 `content` **不是 `ColumnScope`**(是普通的 `@Composable () -> Unit`),
* 所以这里用不了 `Modifier.weight(1f)`(那是 `ColumnScope` 的扩展)——预览区没法「占剩下的高度」。
* 极端的一次分享(4 张图 + 5 份文件 + 一段长正文,键盘还弹着)会把这一个面板顶得很高,
* 而面板在手机上贴着屏幕底(`BottomCenter`):超出的部分会把下面的输入框与两枚按钮挤出屏幕。
* 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、
* 输入框与按钮原地不动。
*
* ## 正文为什么只给三行
* 预览只是「分享进来的是什么」的一眼,**可读的全文在下面那个输入框里**(它是预填的、可滚动可编辑)。
* 预览若把一段几万字的正文整个铺开,这个面板就成了一个阅读器,而用户要的是按一下发送。
* 三行之外用省略号收口,不假装那是全文。
*/
@Composable
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = SHARE_PREVIEW_MAX_HEIGHT)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (preview.images.isNotEmpty()) {
// 与输入栏上方那排待发图片预览同一形态(同样 64dp、同样的圆角与间距),只是**没有**
// 逐张移除的叉:反悔的粒度是整份分享(「取消」),这里不另开一条「只丢掉其中一张」的路
// ——那条路要么改动待发队列、要么改动已取走的那一份,两种都会被「又来了新分享」搅浑。
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
preview.images.forEach { uri ->
AsyncImage(
model = uri,
contentDescription = "这次分享的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
}
}
}
if (preview.documents.isNotEmpty()) {
// 文件芯片就是气泡里那一排(显示名 + 体积),一枚都不藏:上限 5 份会占两到三行。
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
}
if (preview.hasText) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MiuixTheme.colorScheme.surfaceContainer)
.padding(10.dp),
) {
Text(
text = "分享的文字",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Text(
text = preview.text,
style = MiuixTheme.textStyles.footnote1,
maxLines = SHARE_PREVIEW_TEXT_LINES,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
/** 预览里正文给几行(全文在下面那个输入框里)。 */
private const val SHARE_PREVIEW_TEXT_LINES = 3
/**
* 分享进来的一份文件 ⇒ 待发附件。
*
* `ChatViewModel.sendShared` 里也有同一段三字段的搬运(那边是发给上传链的),**不抽公共**是刻意的:
* 抽出来就要动那两个文件(一个是别的并行改动正在碰的 `ChatScreen` 附近,一个是发送路径),
* 而这一处只是给芯片显示用的**只读**形态——真正的发送仍然只经过 `sendShared` 那一条,
* 两条路各有各的用处。第三次复制出现时再抽(本项目对重复的硬规矩是三次起抽)。
*/
private fun SharedDocument.toDraftDocument(): DraftDocument = DraftDocument(
uri = uri,
displayName = displayName,
sizeBytes = sizeBytes,
)
@@ -0,0 +1,148 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
/**
* 分享确认面板的三条判据([shareConfirmPreview] / [shareConfirmDraft] / [shareConfirmCanSend])。
*
* 背景:主人 2026-10-06 拍板——分享进来的内容不再「一到就发」,要像其他 IM 一样先给一个确认面板。
* 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**输入框从哪
* 个值开始**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到的是不是
* 他要发的那一份」。本测试钉住四件事:
*
* 1. **内容一样都不少**:图片与文件按分享进来的顺序原样进预览(多张图的顺序就是发送顺序);
* 2. **空的也算没有**:空地址画出来是一个坏掉的缩略图,全空白的正文占着一行什么也不说;
* 3. **输入框的初值就是分享进来的正文本身,一个字不加**——这是红线:往用户正文里拼来源标记
* (「[用户分享]」之类)等于让用户正文冒充系统指令(`02/F4` / `design/11 §10`);
* 4. **「发送」可点 ≙ 真的会有东西发出去**:与 `sendShared` 开头那条早退同一条判据——那边在
* 「正文空白 + 没有图片 + 没有文件」时原地返回,一次发送都不会发生。
*/
class ShareConfirmTest {
@Test
fun `the preview shows what came in, in the order it came in`() {
val preview = shareConfirmPreview(
SharedContent(
text = "看这个",
images = listOf("file:///cache/share-a.gif", "file:///cache/share-b.png"),
documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt", displayName = "笔记.txt")),
),
)
// 顺序 = 发送顺序(第一条消息带第一张,见 imageSendPlan):预览里换一个顺序就是在替用户
// 改他要发的东西。
assertEquals(listOf("file:///cache/share-a.gif", "file:///cache/share-b.png"), preview.images)
assertEquals(listOf("笔记.txt"), preview.documents.map { it.displayName })
assertEquals("看这个", preview.text)
assertTrue(preview.hasText)
assertFalse(preview.isEmpty)
}
@Test
fun `blank pieces count as nothing to show`() {
val preview = shareConfirmPreview(
SharedContent(
text = " ",
images = listOf("", " "),
documents = listOf(SharedDocument(uri = " ")),
),
)
// 一条空地址画出来就是一个空白缩略图(看着像图坏了);一段全空白的正文占着一行什么也不说。
assertTrue(preview.images.isEmpty())
assertTrue(preview.documents.isEmpty())
assertFalse(preview.hasText)
assertTrue(preview.isEmpty)
}
@Test
fun `a share nothing could be read from has nothing to preview`() {
// `contentUnreadable` 那条分享(对方给了东西、本端连地址都没认下来)照样会走到面板:
// 它的预览是空的。这一档仍由既有的 shareNoticeFor 成句,面板不替它编话。
val preview = shareConfirmPreview(SharedContent(contentUnreadable = true))
assertTrue(preview.isEmpty)
}
@Test
fun `the draft starts as the shared text itself, with nothing added`() {
// 从浏览器分享一个链接进来:那段地址就是用户要发的东西,初值一字不差就是它。
val link = "https://example.com/某个页面?id=1"
assertEquals(link, shareConfirmDraft(SharedContent(text = link)))
// 分享里本来没有正文 ⇒ 空手开始(不是一句模板、也不是一个空行)。
assertEquals("", shareConfirmDraft(SharedContent(images = listOf("file:///cache/share-a.png"))))
// 两侧空白归一(与 ShareRules.extract 取正文那一处同一个口径)。
assertEquals("看这个", shareConfirmDraft(SharedContent(text = " 看这个 ")))
}
@Test
fun `the draft is exactly what the preview shows`() {
// 两边必须同值:预览里那一块说的是「分享进来的是什么」,而输入框的初值就是它的可编辑副本。
// 分成两个判据之后若各写各的,窗口里就会出现「预览显示 A、输入框里是 B」这种一眼看去
// 分不清要发哪一份的状态。
listOf(
SharedContent(text = "看这个"),
SharedContent(text = " 带空白的正文 "),
SharedContent(images = listOf("file:///cache/share-a.png")),
SharedContent(),
).forEach { share ->
assertEquals(
"预览与输入框的初值必须同源",
shareConfirmPreview(share).text,
shareConfirmDraft(share),
)
}
}
@Test
fun `the draft never carries a source marker`() {
val draft = shareConfirmDraft(SharedContent(text = "看这个"))
// 来源标识走后端的 RoleSystem 来源消息 + 结构化 SenderName,人类可见的标记只能放在本端
// 渲染层——把它拼进正文 = 让用户正文冒充系统指令,项目红线。
assertEquals("看这个", draft)
assertFalse(draft.contains("分享"))
assertFalse(draft.contains("[用户"))
}
@Test
fun `send is offered whenever something would really go out`() {
val nothing = SharedContent()
val text = SharedContent(text = "看这个")
val image = SharedContent(images = listOf("file:///cache/share-a.png"))
val document = SharedContent(documents = listOf(SharedDocument(uri = "file:///cache/share-0.txt")))
// 用户改过的正文也算(他把分享的链接改短、或者换上自己想说的话)。
assertTrue(shareConfirmCanSend(text, "改过的正文"))
assertTrue(shareConfirmCanSend(nothing, "只写一句话也行"))
// 纯图片 / 纯文件照样可发:服务端支持只带 image / documents 的回合,本端不因为「没写文字」拦它。
assertTrue(shareConfirmCanSend(image, ""))
assertTrue(shareConfirmCanSend(document, ""))
assertTrue(shareConfirmCanSend(image, " "))
// 把正文整段删光,图片与文件照样发得出去:用户反悔的只是那段文字,不是这次分享的其余部分。
val textAndImage = SharedContent(text = "看这个", images = listOf("file:///cache/share-a.png"))
assertTrue(shareConfirmCanSend(textAndImage, ""))
}
@Test
fun `send is not offered when nothing at all would go out`() {
// 这条与 `sendShared` 开头那条早退**同一条**:正文(去空白后)为空、图片为空、文件也为空时
// 那边原地 `return`——一次发送都不会发生。键若画成可点的,用户按下去只看到面板关掉、
// 什么都没发生,而他会以为自己少发了一条。
assertFalse(shareConfirmCanSend(SharedContent(), ""))
assertFalse(shareConfirmCanSend(SharedContent(), " "))
assertFalse(shareConfirmCanSend(SharedContent(text = "看这个"), " "))
// 正文被删光的**纯文本分享**(一个链接分享就是这一档)就什么都不剩了——此时没有可发的
// 东西,而这正是「发送」该变灰的那种情形。
assertFalse(shareConfirmCanSend(SharedContent(text = "看这个"), ""))
// 只剩「丢件计数」的分享(有 2 张图读不到)同样没有可发的东西。
assertFalse(shareConfirmCanSend(SharedContent(failedImages = 2), ""))
}
}