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
@@ -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