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:
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), ""))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user