diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index cec623e..b9a89ec 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -405,7 +405,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] 是 // 原子出队、没有归还路径 ⇒ 那一次「其实一次都没发出去」的失败会把内容从待发队列里抹掉, @@ -420,6 +422,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 // 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走: @@ -429,6 +436,16 @@ fun ChatScreen( // 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑) // 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。 var shareToldWaiting by remember(sessionId) { mutableStateOf(false) } + // 「这一份分享已经取走、正在等用户确认」。它此前**没有地方待**——取到就直接送进发送链了; + // 现在取到的那一份停在这格 Compose 状态里,确认面板靠它渲染(`ShareConfirmPanel`)。 + // + // 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着, + // 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。 + var shareConfirm by remember(sessionId) { mutableStateOf(null) } + // 面板输入框里的文字(初值 = 分享进来的正文本身,见 [shareConfirmDraft])。它与上面那份内容 + // **分开存**是刻意的:预览要显示的是「分享进来的是什么」,用户改的是「这次要发出去的正文」, + // 改过之后这两件事就不再相同,合成一个字段会让预览跟着输入框一起变。 + var shareDraft by remember(sessionId) { mutableStateOf("") } LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) { if (pendingShares.isEmpty() || shareConnection == WsState.Connected) { shareWaitedLong = false @@ -438,8 +455,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, @@ -448,9 +467,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] 定, @@ -790,6 +818,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 = "" + }, + ) + } } /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt new file mode 100644 index 0000000..dce93d4 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt @@ -0,0 +1,293 @@ +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 其实自带两族(**没有**旧版本的 + * `extra.*` 包,别照那个去猜 import):`top.yukonga.miuix.kmp.window.*`(`WindowDialog` / + * `WindowBottomSheet`,真正的弹窗)与 `top.yukonga.miuix.kmp.overlay.*`(`OverlayDialog` / + * `OverlayBottomSheet`,在当前组合里当一层浮层)。 + * + * 本面板用 **window 族**,两个理由: + * + * 1. **overlay 族在调用点上是走不通的**(不是偏好,是硬约束):`OverlayDialog` 靠 + * `MiuixPopupUtils.DialogLayout` 把自己登记进 `LocalDialogStates` / `LocalRootDialogStates`, + * 而这两个 `staticCompositionLocalOf` **只有 Miuix 的 `Scaffold` 会 provide**(`basic/Scaffold.kt`)。 + * 本面板的调用点在 `ChatScreen` 里那个 `Scaffold` 的**外面**(与附件预览 / 编辑 / 删除那三个弹层 + * 同一处),拿不到宿主——而它的默认值是一个没人读的空列表,于是**不报错、也不显示**: + * 分享进来会变成「什么都没发生」,正是本项目最忌讳的那种静默失败。(同批的 W53S 用的是 + * `OverlayDialog`,因为那个确认框就长在设置页的 `Scaffold` 里——两处调用点的处境不同。) + * 2. 它与既有那四个弹层是**同一个形态**:独立窗口、自带遮罩、点面板外或按返回键即取消 + * (`onDismissRequest`,这里接的正是「取消」)。这里要的只是一次确认,不需要底部面板那套 + * 可拖拽的手势。 + * + * ## 预览与输入框的分工 + * 上半是**预览**(图片缩略图 / 文件芯片 / 分享正文),下半是**输入框**(初值就是分享进来的正文, + * 见 [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, + /** 要显示的文件(显示名 + 体积由既有的 `DocumentChipRow` 负责换算)。 */ + val documents: List, + /** 要显示的正文;空白已归一成空串。 */ + 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, +) diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt new file mode 100644 index 0000000..4d7a1c0 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt @@ -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), "")) + } +}