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

@@ -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), ""))
}
}