merge: W42 —— 图片入口改多选,N 张图按既有切分落成 N 条消息
This commit is contained in:
commit
4355ebddea
7 files changed
+354
-52
No files matched your search
@@ -4,7 +4,7 @@ import java.io.ByteArrayOutputStream
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImage` 平行的概念)。
|
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImages` 平行的概念)。
|
||||||
*
|
*
|
||||||
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
|
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
|
||||||
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
|
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
|
||||||
|
|||||||
@@ -151,6 +151,18 @@ object ShareRules {
|
|||||||
*/
|
*/
|
||||||
const val MAX_DOCUMENTS = 5
|
const val MAX_DOCUMENTS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片超限的那一句(**纯函数**):**分享入口与图片选择入口共用同一句**,所以它只有一个出处。
|
||||||
|
*
|
||||||
|
* 两个入口的上限本来就是同一个数([MAX_IMAGES])⇒ 告知也必须是同一句:各写一句,迟早会出现
|
||||||
|
* 「分享说只发前 4 张、选图说只发前 4 张(措辞不同)」这种同一件事的两种说法。
|
||||||
|
*
|
||||||
|
* @param kept 这次真正发出去的张数(`SharedContent.images` 的长度 / `ImageSendPlan.imagesToSend` 的长度)
|
||||||
|
* @param dropped 被上限截掉的张数;0 ⇒ `null`(一张没少就没什么要说的,说话才是噪声)
|
||||||
|
*/
|
||||||
|
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
|
||||||
|
if (dropped > 0) "分享的图片超过 $MAX_IMAGES 张,只发送前 $kept 张" else null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从分享 Intent 的原始字段提取内容。
|
* 从分享 Intent 的原始字段提取内容。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -315,11 +315,15 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
var imageTarget by remember { mutableStateOf<ImageSource?>(null) }
|
var imageTarget by remember { mutableStateOf<ImageSource?>(null) }
|
||||||
|
|
||||||
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
|
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
|
||||||
|
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
|
||||||
|
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
|
||||||
|
// (老系统 / 没有 Photo Picker 时它退化成 `ACTION_OPEN_DOCUMENT`,那份 Intent 只能表达
|
||||||
|
// 「可多选」、表达不了「最多几张」)。真超了由 VM 如实告知、**绝不静默截断**(见 onImagesPicked)。
|
||||||
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
|
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
|
||||||
val imagePicker = rememberLauncherForActivityResult(
|
val imagePicker = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.PickVisualMedia(),
|
ActivityResultContracts.PickMultipleVisualMedia(ShareRules.MAX_IMAGES),
|
||||||
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
|
) { uris -> viewModel.onImagesPicked(uris.map { it.toString() }) }
|
||||||
|
|
||||||
// 文件选择器(SAF `OpenMultipleDocuments`):同样不需要任何存储权限——授权是**逐次**给的,
|
// 文件选择器(SAF `OpenMultipleDocuments`):同样不需要任何存储权限——授权是**逐次**给的,
|
||||||
// 因此这条 URI 的读授权只活到本 Activity 任务结束;而发送可能发生在登录之后(分享落地)
|
// 因此这条 URI 的读授权只活到本 Activity 任务结束;而发送可能发生在登录之后(分享落地)
|
||||||
@@ -451,7 +455,7 @@ fun ChatScreen(
|
|||||||
bottomBar = {
|
bottomBar = {
|
||||||
InputBar(
|
InputBar(
|
||||||
value = state.draft,
|
value = state.draft,
|
||||||
imageUri = state.draftImage,
|
images = state.draftImages,
|
||||||
documents = state.draftDocuments,
|
documents = state.draftDocuments,
|
||||||
busyText = busyLabel(state),
|
busyText = busyLabel(state),
|
||||||
onValueChange = viewModel::onDraftChange,
|
onValueChange = viewModel::onDraftChange,
|
||||||
@@ -461,7 +465,7 @@ fun ChatScreen(
|
|||||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClearImage = viewModel::clearDraftImage,
|
onRemoveImage = viewModel::removeDraftImage,
|
||||||
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
|
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
|
||||||
onRemoveDocument = viewModel::removeDraftDocument,
|
onRemoveDocument = viewModel::removeDraftDocument,
|
||||||
onPreviewDocument = { index ->
|
onPreviewDocument = { index ->
|
||||||
@@ -756,13 +760,13 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null)
|
|||||||
@Composable
|
@Composable
|
||||||
private fun InputBar(
|
private fun InputBar(
|
||||||
value: String,
|
value: String,
|
||||||
imageUri: String?,
|
images: List<String>,
|
||||||
documents: List<DraftDocument>,
|
documents: List<DraftDocument>,
|
||||||
busyText: String,
|
busyText: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onPickImage: () -> Unit,
|
onPickImage: () -> Unit,
|
||||||
onClearImage: () -> Unit,
|
onRemoveImage: (Int) -> Unit,
|
||||||
onPickDocument: () -> Unit,
|
onPickDocument: () -> Unit,
|
||||||
onRemoveDocument: (Int) -> Unit,
|
onRemoveDocument: (Int) -> Unit,
|
||||||
onPreviewDocument: (Int) -> Unit,
|
onPreviewDocument: (Int) -> Unit,
|
||||||
@@ -773,7 +777,7 @@ private fun InputBar(
|
|||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
||||||
) {
|
) {
|
||||||
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
|
DraftImagePreviews(images = images, onRemove = onRemoveImage)
|
||||||
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
|
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
|
||||||
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
|
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
|
||||||
DraftDocumentChips(
|
DraftDocumentChips(
|
||||||
@@ -782,8 +786,9 @@ private fun InputBar(
|
|||||||
onClick = onPreviewDocument,
|
onClick = onPreviewDocument,
|
||||||
)
|
)
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
|
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
|
||||||
if (imageUri == null) {
|
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
|
||||||
|
if (images.size < ShareRules.MAX_IMAGES) {
|
||||||
IconButton(onClick = onPickImage) {
|
IconButton(onClick = onPickImage) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = MiuixIcons.Image,
|
imageVector = MiuixIcons.Image,
|
||||||
@@ -815,9 +820,9 @@ private fun InputBar(
|
|||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
// 附件可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
||||||
// 纯附件回合服务端**支持**)。
|
// 纯附件回合服务端**支持**)。
|
||||||
enabled = value.isNotBlank() || imageUri != null || documents.isNotEmpty(),
|
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
Text("发送")
|
Text("发送")
|
||||||
}
|
}
|
||||||
@@ -854,15 +859,24 @@ private fun DraftDocumentChips(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
|
/**
|
||||||
|
* 待发图片的一排本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。
|
||||||
|
*
|
||||||
|
* 多选之后不再是「一张 + 一枚移除键」:每一张各自带一枚移除键,**顺序就是发送顺序**
|
||||||
|
* (第一条消息带第一张,见 [imageSendPlan])。刻意不做横向滚动——上限 4 张,一排放得下;
|
||||||
|
* 也不做「只显示前 N 张 + 一个计数」,那会让人以为自己选的某张不见了。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
|
private fun DraftImagePreviews(images: List<String>, onRemove: (Int) -> Unit) {
|
||||||
|
if (images.isEmpty()) return
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 6.dp),
|
.padding(bottom = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
images.forEachIndexed { index, uri ->
|
||||||
Box {
|
Box {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = uri,
|
model = uri,
|
||||||
@@ -872,7 +886,7 @@ private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
|
|||||||
.clip(RoundedCornerShape(10.dp)),
|
.clip(RoundedCornerShape(10.dp)),
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onClear,
|
onClick = { onRemove(index) },
|
||||||
modifier = Modifier.align(Alignment.TopEnd),
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -884,6 +898,7 @@ private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CenterHint(text: String) {
|
private fun CenterHint(text: String) {
|
||||||
@@ -995,7 +1010,9 @@ fun shareNoticeFor(share: SharedContent): String? {
|
|||||||
if (!share.worthDelivering) return null
|
if (!share.worthDelivering) return null
|
||||||
val parts = buildList {
|
val parts = buildList {
|
||||||
if (share.truncatedImages > 0) {
|
if (share.truncatedImages > 0) {
|
||||||
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张")
|
// 这一句只有一处出处([ShareRules.truncatedImagesNotice]):图片选择入口与分享入口的
|
||||||
|
// 上限是同一个数,所以告知也共用同一句。
|
||||||
|
ShareRules.truncatedImagesNotice(share.images.size, share.truncatedImages)?.let(::add)
|
||||||
}
|
}
|
||||||
if (share.truncatedDocuments > 0) {
|
if (share.truncatedDocuments > 0) {
|
||||||
add("分享的文件超过 ${ShareRules.MAX_DOCUMENTS} 个,只发送前 ${share.documents.size} 个")
|
add("分享的文件超过 ${ShareRules.MAX_DOCUMENTS} 个,只发送前 ${share.documents.size} 个")
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import top.yeij.cyrene.domain.chat.withSteeredMessage
|
|||||||
import top.yeij.cyrene.domain.chat.withTurnBegin
|
import top.yeij.cyrene.domain.chat.withTurnBegin
|
||||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||||
import top.yeij.cyrene.domain.parse.StreamingContentParser
|
import top.yeij.cyrene.domain.parse.StreamingContentParser
|
||||||
|
import top.yeij.cyrene.domain.share.ShareRules
|
||||||
import top.yeij.cyrene.domain.share.SharedContent
|
import top.yeij.cyrene.domain.share.SharedContent
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -355,7 +356,9 @@ fun ChatUiState.rollbackPending(
|
|||||||
return copy(
|
return copy(
|
||||||
pending = pending.filterNot { it.localId == localId },
|
pending = pending.filterNot { it.localId == localId },
|
||||||
draft = failed?.text?.ifBlank { draft } ?: draft,
|
draft = failed?.text?.ifBlank { draft } ?: draft,
|
||||||
draftImage = failed?.imageUri ?: draftImage,
|
// 挂在失败那条气泡上的图片**补回**待发列表(而不是覆盖):N 张图是 N 条消息,各失败各的,
|
||||||
|
// 谁失败谁把自己那一张还给输入栏,没失败的那几条照旧在飞。
|
||||||
|
draftImages = failed?.imageUri?.let { draftImages + it } ?: draftImages,
|
||||||
draftDocuments = failed?.documents?.takeIf { it.isNotEmpty() } ?: draftDocuments,
|
draftDocuments = failed?.documents?.takeIf { it.isNotEmpty() } ?: draftDocuments,
|
||||||
error = reason,
|
error = reason,
|
||||||
errorIsQueueFullNotice = queueFullNotice,
|
errorIsQueueFullNotice = queueFullNotice,
|
||||||
@@ -579,10 +582,17 @@ data class ChatUiState(
|
|||||||
*/
|
*/
|
||||||
val streams: List<StreamingLeg> = emptyList(),
|
val streams: List<StreamingLeg> = emptyList(),
|
||||||
val draft: String = "",
|
val draft: String = "",
|
||||||
/** 已选好、还没发出去的图片(本地 URI 字符串);null = 没有待发图片。 */
|
|
||||||
val draftImage: String? = null,
|
|
||||||
/**
|
/**
|
||||||
* 已选好、还没发出去的文档附件(与 [draftImage] 平行的概念)。发送失败时与正文、图片一起
|
* 已选好、还没发出去的图片(本地 URI 字符串),**顺序 = 用户选图的顺序**(也就是发送顺序)。
|
||||||
|
*
|
||||||
|
* 它是一个列表(W42 主人定案:图片入口要能多选):契约里 `chat.image` 是**单图字段**,所以
|
||||||
|
* N 张图要落成 N 条消息,切分判据是纯函数 [imageSendPlan](第一条带正文 + 第一张图 + 全部附件)。
|
||||||
|
* 上限 [ShareRules.MAX_IMAGES] 在入口处就压住(免得攒得没边),发送那处照旧再判一次——
|
||||||
|
* 规则本体在 [imageSendPlan],入口只是不许超。
|
||||||
|
*/
|
||||||
|
val draftImages: List<String> = emptyList(),
|
||||||
|
/**
|
||||||
|
* 已选好、还没发出去的文档附件(与 [draftImages] 平行的概念)。发送失败时与正文、图片一起
|
||||||
* 回到这里(见 [ChatViewModel.rollback]),所以它们必须一路带着**本地引用**——
|
* 回到这里(见 [ChatViewModel.rollback]),所以它们必须一路带着**本地引用**——
|
||||||
* 重新上传的代价是几 MB 的流量与一次白等。
|
* 重新上传的代价是几 MB 的流量与一次白等。
|
||||||
*/
|
*/
|
||||||
@@ -935,11 +945,37 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
|
fun onDraftChange(text: String) = _state.update { it.copy(draft = text) }
|
||||||
|
|
||||||
/** 选好待发图片(本地 `content://` URI);传 null 或空白串即清除。 */
|
/**
|
||||||
fun onImagePicked(uri: String?) =
|
* 选好待发图片(系统相册多选回来的本地 `content://` URI,**顺序即发送顺序**)。
|
||||||
_state.update { it.copy(draftImage = uri?.takeIf { value -> value.isNotBlank() }) }
|
*
|
||||||
|
* 与附件那条([onDocumentsPicked])同一形态:**追加**在已有待发图片之后(分两次选的图也该按
|
||||||
|
* 选的先后发出去),并把总数压在 [ShareRules.MAX_IMAGES] 以内。
|
||||||
|
*
|
||||||
|
* 超限的那几张**不静默丢**:说一句。文案与判据都沿用分享路径那一句
|
||||||
|
* ([ShareRules.truncatedImagesNotice])——两个入口的上限本来就是同一个数,各写一句迟早会
|
||||||
|
* 变成两种口径。
|
||||||
|
*
|
||||||
|
* 空选择(用户在选择器里取消)时 [uris] 是空的:**什么都不做**(与今天单选回调拿到 null 时
|
||||||
|
* 一致:不弹气泡、不动草稿、不说一句话)。
|
||||||
|
*/
|
||||||
|
fun onImagesPicked(uris: List<String>) {
|
||||||
|
val picked = uris.map(String::trim).filter(String::isNotEmpty)
|
||||||
|
if (picked.isEmpty()) return
|
||||||
|
val current = _state.value.draftImages
|
||||||
|
val kept = (current + picked).take(ShareRules.MAX_IMAGES)
|
||||||
|
_state.update { it.copy(draftImages = kept) }
|
||||||
|
ShareRules.truncatedImagesNotice(kept.size, current.size + picked.size - kept.size)
|
||||||
|
?.let(::showShareNotice)
|
||||||
|
}
|
||||||
|
|
||||||
fun clearDraftImage() = _state.update { it.copy(draftImage = null) }
|
/** 移除第 [index] 张待发图片(越界原样不动,UI 的移除按钮不必自己判界)。 */
|
||||||
|
fun removeDraftImage(index: Int) = _state.update { state ->
|
||||||
|
if (index !in state.draftImages.indices) {
|
||||||
|
state
|
||||||
|
} else {
|
||||||
|
state.copy(draftImages = state.draftImages.filterIndexed { i, _ -> i != index })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选好待发附件(SAF `OpenDocument` / `OpenMultipleDocuments` 回来的 URI)。
|
* 选好待发附件(SAF `OpenDocument` / `OpenMultipleDocuments` 回来的 URI)。
|
||||||
@@ -1136,16 +1172,29 @@ class ChatViewModel(
|
|||||||
*
|
*
|
||||||
* 附件**一律先上传**换服务端存储名(`POST /api/v1/documents`),再随 `chat.documents` 引用
|
* 附件**一律先上传**换服务端存储名(`POST /api/v1/documents`),再随 `chat.documents` 引用
|
||||||
* 发出,上行只带 `name`(见 [DocumentDelivery.payload] 与 [uploadDocuments])。
|
* 发出,上行只带 `name`(见 [DocumentDelivery.payload] 与 [uploadDocuments])。
|
||||||
|
*
|
||||||
|
* 输入栏里可以选**多张**图(W42 主人定案:「那肯定要多选」):契约里 `chat.image` 是单图字段,
|
||||||
|
* 所以 N 张 = N 条消息,切分判据是纯函数 [imageSendPlan],投递那一段与分享落地**共用同一个
|
||||||
|
* 函数**([emitPlannedMessages])——两条路各写一份循环,迟早会在「哪条带正文 / 哪条带附件 /
|
||||||
|
* 超过上限怎么办」上分叉。
|
||||||
*/
|
*/
|
||||||
fun send() {
|
fun send() {
|
||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
val text = snapshot.draft.trim()
|
val text = snapshot.draft.trim()
|
||||||
val imageUri = snapshot.draftImage
|
val images = snapshot.draftImages
|
||||||
val documents = snapshot.draftDocuments
|
val documents = snapshot.draftDocuments
|
||||||
// 空输入的四件事都不发生(不上屏、不排队、不发帧、不说话)——判据与投递口**同一个函数**
|
// 空输入的四件事都不发生(不上屏、不排队、不发帧、不说话)——判据与投递口**同一个函数**
|
||||||
// ([hasSendableContent]),免得入口与投递口对「有没有东西可发」各有一套说法。
|
// ([hasSendableContent]),免得入口与投递口对「有没有东西可发」各有一套说法。
|
||||||
if (!hasSendableContent(text, imageUri, documents)) return
|
// 这个判据只问「有没有至少一张图」,切分要几张由 [imageSendPlan] 回答,两件事各归各的。
|
||||||
viewModelScope.launch { emitAndDeliver(text, imageUri, documents, clearDraft = true) }
|
if (!hasSendableContent(text, images.firstOrNull(), documents)) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
emitPlannedMessages(
|
||||||
|
plan = imageSendPlan(text, images, documents),
|
||||||
|
clearDraft = true,
|
||||||
|
applySendPreference = true,
|
||||||
|
localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1157,6 +1206,8 @@ class ChatViewModel(
|
|||||||
* 非图片文件([SharedContent.documents])没有这个限制——它们与正文、图片挂在**同一条**
|
* 非图片文件([SharedContent.documents])没有这个限制——它们与正文、图片挂在**同一条**
|
||||||
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
|
* 消息上(`chat.documents` 本就是数组,≤5 条),因此只随第一条走。
|
||||||
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
|
* 顺序由这里串起来(同一个协程里依次投递)——各投各的会让它们互相赛跑,落库顺序反过来。
|
||||||
|
* 这套切分与顺序**不是这里独有的**:输入栏的图片入口也能多选(W42),两者走的是同一个纯函数
|
||||||
|
* ([imageSendPlan])与同一段投递([emitPlannedMessages])。
|
||||||
*
|
*
|
||||||
* ## 「分享进来的」这条事实写在幂等键上(角标的唯一判据)
|
* ## 「分享进来的」这条事实写在幂等键上(角标的唯一判据)
|
||||||
* 这里给每一条投递传 `localIdPrefix = "share-"`,于是铸出来的键形如 `share-<uuid>`,
|
* 这里给每一条投递传 `localIdPrefix = "share-"`,于是铸出来的键形如 `share-<uuid>`,
|
||||||
@@ -1170,7 +1221,7 @@ class ChatViewModel(
|
|||||||
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
|
* - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。
|
||||||
*
|
*
|
||||||
* ## 分享不声明(`applySendPreference` 恒为 false)
|
* ## 分享不声明(`applySendPreference` 恒为 false)
|
||||||
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,上面那个 `forEach` 会逐条投递,
|
* 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递,
|
||||||
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
|
* 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。
|
||||||
* 两种声明在这里**都**是错的:
|
* 两种声明在这里**都**是错的:
|
||||||
*
|
*
|
||||||
@@ -1227,24 +1278,12 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
// 每一条都带 `share-` 前缀(多张图 = 多条消息,它们**都是**分享进来的):
|
// 每一条都带 `share-` 前缀(多张图 = 多条消息,它们**都是**分享进来的):
|
||||||
// 角标由此在渲染层长出来,正文照旧一字未改。
|
// 角标由此在渲染层长出来,正文照旧一字未改。
|
||||||
emitAndDeliver(
|
emitPlannedMessages(
|
||||||
text,
|
plan = imageSendPlan(text, share.images, files),
|
||||||
share.images.firstOrNull(),
|
|
||||||
files,
|
|
||||||
clearDraft = false,
|
clearDraft = false,
|
||||||
applySendPreference = false,
|
applySendPreference = false,
|
||||||
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
|
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
|
||||||
)
|
)
|
||||||
share.images.drop(1).forEach {
|
|
||||||
emitAndDeliver(
|
|
||||||
"",
|
|
||||||
it,
|
|
||||||
emptyList(),
|
|
||||||
clearDraft = false,
|
|
||||||
applySendPreference = false,
|
|
||||||
localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1309,14 +1348,63 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按一份切分([ImageSendPlan])把这次发送**逐条投递**([send] 与 [sendShared] 共用这一段)。
|
||||||
|
*
|
||||||
|
* ## 为什么两条路共用
|
||||||
|
* 「一次发送带 N 张图」的规则只有一条(契约里 `chat.image` 是单图字段 ⇒ N 张 = N 条),而两条路
|
||||||
|
* 都可能有 N 张:输入栏那条([send],草稿里就是多张图)与分享落地那条([sendShared])。切分判据
|
||||||
|
* 是纯函数 [imageSendPlan](可单测),投递**只此一段**:各写一份循环,迟早会在「哪条带正文 /
|
||||||
|
* 哪条带附件 / 哪条清草稿」上分叉。
|
||||||
|
*
|
||||||
|
* ## 顺序、静默
|
||||||
|
* 顺序就是 [ImageSendPlan.messages] 的顺序(= 用户选图的顺序)——同一个协程里依次投递,
|
||||||
|
* 各投各的会让它们互相赛跑、落库顺序反过来。上限之外的那几张**不静默丢**:说一句
|
||||||
|
* (文案 [ShareRules.truncatedImagesNotice],与分享路径同一句),而且**在投递之前**说——
|
||||||
|
* 它说的是「这次只发前几张」,不是一次失败,用户该在消息上屏的同时看到它。
|
||||||
|
*
|
||||||
|
* ## 偏好至多声明一次(落在第一条上)
|
||||||
|
* 「她忙时发消息」那个偏好会逐条声明打断 / 并行,而一次发送切出来的 N 条**不是 N 次发送**:
|
||||||
|
* 第 2 条起声明打断,就是在**打断自己刚起的上一轮**(最后只剩最后一张图的回答,前面几条的回合
|
||||||
|
* 被取消——理由与 [sendShared] 的「分享不声明」逐字相同)。所以这一次发送**至多声明一次**,
|
||||||
|
* 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则
|
||||||
|
* 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。
|
||||||
|
*
|
||||||
|
* @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送,
|
||||||
|
* 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。
|
||||||
|
* @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的
|
||||||
|
* 那条传 false(分享不是用户按的发送键,从不声明)。
|
||||||
|
* @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。
|
||||||
|
*/
|
||||||
|
private suspend fun emitPlannedMessages(
|
||||||
|
plan: ImageSendPlan,
|
||||||
|
clearDraft: Boolean,
|
||||||
|
applySendPreference: Boolean,
|
||||||
|
localIdPrefix: String,
|
||||||
|
) {
|
||||||
|
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages)
|
||||||
|
?.let(::showShareNotice)
|
||||||
|
plan.messages.forEachIndexed { index, message ->
|
||||||
|
emitAndDeliver(
|
||||||
|
text = message.text,
|
||||||
|
imageUri = message.imageUri,
|
||||||
|
documents = message.documents,
|
||||||
|
clearDraft = clearDraft && index == 0,
|
||||||
|
applySendPreference = applySendPreference && index == 0,
|
||||||
|
localIdPrefix = localIdPrefix,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。
|
* 把一条消息乐观上屏并投递出去([send] 与 [sendShared] 共用)。
|
||||||
*
|
*
|
||||||
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
* @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条;
|
||||||
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
* 分享落地是替用户发一条他自己录进来的内容,草稿不是它。
|
||||||
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
|
* @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明:
|
||||||
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;只有 [sendShared]
|
* `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false:
|
||||||
* 传 false——它是批量动作,批量里逐条声明只会自相干扰(两种声明各自的理由见 [sendShared])。
|
* [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些
|
||||||
|
* ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。
|
||||||
* @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认),
|
* @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认),
|
||||||
* [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在
|
* [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在
|
||||||
* 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动**
|
* 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动**
|
||||||
@@ -1354,7 +1442,7 @@ class ChatViewModel(
|
|||||||
it.copy(
|
it.copy(
|
||||||
pending = it.pending + PendingMessage(localId, text, imageUri, documents),
|
pending = it.pending + PendingMessage(localId, text, imageUri, documents),
|
||||||
draft = if (clearDraft) "" else it.draft,
|
draft = if (clearDraft) "" else it.draft,
|
||||||
draftImage = if (clearDraft) null else it.draftImage,
|
draftImages = if (clearDraft) emptyList() else it.draftImages,
|
||||||
draftDocuments = if (clearDraft) emptyList() else it.draftDocuments,
|
draftDocuments = if (clearDraft) emptyList() else it.draftDocuments,
|
||||||
error = null,
|
error = null,
|
||||||
)
|
)
|
||||||
@@ -1411,7 +1499,8 @@ class ChatViewModel(
|
|||||||
//
|
//
|
||||||
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。
|
// 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。
|
||||||
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍
|
// [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍
|
||||||
// 见 [sendShared] 的「分享不声明」一段)。
|
// 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true
|
||||||
|
// (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。
|
||||||
val mode = currentInterruptMode()
|
val mode = currentInterruptMode()
|
||||||
val busy = _state.value.busyMode != null
|
val busy = _state.value.busyMode != null
|
||||||
if (applySendPreference) interruptRunningTurn(mode, busy)
|
if (applySendPreference) interruptRunningTurn(mode, busy)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package top.yeij.cyrene.ui.chat
|
||||||
|
|
||||||
|
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||||
|
import top.yeij.cyrene.domain.share.ShareRules
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次发送切出来的一条消息([imageSendPlan] 的元素)。
|
||||||
|
*
|
||||||
|
* [imageUri] 是这条消息要带的那张图(契约里 `chat.image` 是**单图字段**);[documents] 是这条
|
||||||
|
* 消息要带的非图片附件(`chat.documents` 是数组,但一次只挂在一条消息上)。
|
||||||
|
*/
|
||||||
|
data class PlannedMessage(
|
||||||
|
val text: String,
|
||||||
|
val imageUri: String?,
|
||||||
|
val documents: List<DraftDocument>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 一次发送的切分结果([imageSendPlan] 的返回值)。 */
|
||||||
|
data class ImageSendPlan(
|
||||||
|
/** 要投递的消息,顺序 = 用户选图的顺序。**至少一条**:没有图时它就是承载正文与附件的那条。 */
|
||||||
|
val messages: List<PlannedMessage>,
|
||||||
|
/** 超过 [ShareRules.MAX_IMAGES] 而**不会**发出去的张数(0 = 一张没少)。 */
|
||||||
|
val truncatedImages: Int,
|
||||||
|
) {
|
||||||
|
/** 这次真正会发出去的图片(顺序不变)。超限告知里说的「前 N 张」,N 就是它的长度。 */
|
||||||
|
val imagesToSend: List<String> get() = messages.mapNotNull { it.imageUri }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「一次发送(正文 + N 张图 + 若干附件)要切成几条消息、每条带什么、超限几张」——**纯函数**,
|
||||||
|
* 与 Android 无关、可单测(见 `ImageSendPlanTest`)。
|
||||||
|
*
|
||||||
|
* ## 为什么 N 张图 = N 条消息
|
||||||
|
* 契约里 `chat.image` 是**单图字段**:一次上行只能带一张。所以多选进来的 N 张只能落成 N 条消息,
|
||||||
|
* 顺序 = 用户选图的顺序,而**第一条**带上正文与全部附件——正文是用户打的这一段字、附件是挂在
|
||||||
|
* 这条消息上的数组,两者本来就只该出现一次。第二条起每条只有一张图、正文为空:空正文的那几条
|
||||||
|
* 不会凭空替用户多说一个字。
|
||||||
|
*
|
||||||
|
* ## 上限只在这里判一次
|
||||||
|
* 上限 [ShareRules.MAX_IMAGES] 与分享路径用**同一个数**(4):两条路都是「一次最多接住几张图」。
|
||||||
|
* 多出来的张数由 [ImageSendPlan.truncatedImages] 如实报出来,调用方据此说一句
|
||||||
|
* (文案 [ShareRules.truncatedImagesNotice])——**绝不静默截断**。
|
||||||
|
*
|
||||||
|
* ## N = 0 为什么也回一条
|
||||||
|
* 没有图时这一次发送仍然是一条普通消息(正文 + 附件),它是同一条规则的空形态。把「有没有东西
|
||||||
|
* 可发」留给 [hasSendableContent] 判,这里只回答「要切几条、每条带什么」——两个判据各管一件事,
|
||||||
|
* 谁也不替谁下结论。空串 / 重复项由调用方在入口处清掉(草稿与分享两条入口本来就各自清了)。
|
||||||
|
*/
|
||||||
|
fun imageSendPlan(
|
||||||
|
text: String,
|
||||||
|
images: List<String>,
|
||||||
|
documents: List<DraftDocument>,
|
||||||
|
): ImageSendPlan {
|
||||||
|
val kept = images.take(ShareRules.MAX_IMAGES)
|
||||||
|
val messages = if (kept.isEmpty()) {
|
||||||
|
listOf(PlannedMessage(text = text, imageUri = null, documents = documents))
|
||||||
|
} else {
|
||||||
|
kept.mapIndexed { index, uri ->
|
||||||
|
PlannedMessage(
|
||||||
|
text = if (index == 0) text else "",
|
||||||
|
imageUri = uri,
|
||||||
|
documents = if (index == 0) documents else emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ImageSendPlan(
|
||||||
|
messages = messages,
|
||||||
|
truncatedImages = images.size - kept.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package top.yeij.cyrene.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||||
|
import top.yeij.cyrene.domain.share.ShareRules
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「一次发送带 N 张图」的切分判据([imageSendPlan])。
|
||||||
|
*
|
||||||
|
* 背景(W42 主人定案:「那肯定要多选」):契约里 `chat.image` 是**单图字段**,所以 N 张图 = N 条
|
||||||
|
* 消息,第一条带正文 + 第一张图 + 全部附件,其余每条一张图。切错的两种样子用户都看得见——正文挂错
|
||||||
|
* 条(第二张图把用户打的那段字带走了)、图少发一张(选了几张、发出去几张对不上)。而它本身是纯
|
||||||
|
* 函数:不需要组合界面,就能把 N = 0 / 1 / 2 / 4(正好到顶)/ 5(超限)逐条穷举。
|
||||||
|
*
|
||||||
|
* 顺带钉住超限的那句告知:它与分享路径**同一句**([ShareRules.truncatedImagesNotice]),因为两个
|
||||||
|
* 入口的上限本来就是同一个数。
|
||||||
|
*/
|
||||||
|
class ImageSendPlanTest {
|
||||||
|
|
||||||
|
private val doc = DraftDocument(uri = "content://doc", displayName = "笔记.md")
|
||||||
|
|
||||||
|
private fun images(n: Int): List<String> = (1..n).map { "content://img$it" }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no image is one plain message carrying the text and the documents`() {
|
||||||
|
val plan = imageSendPlan(text = "看这个", images = emptyList(), documents = listOf(doc))
|
||||||
|
|
||||||
|
assertEquals(1, plan.messages.size)
|
||||||
|
assertEquals("看这个", plan.messages.single().text)
|
||||||
|
assertNull(plan.messages.single().imageUri)
|
||||||
|
assertEquals(listOf(doc), plan.messages.single().documents)
|
||||||
|
assertEquals(0, plan.truncatedImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty send still plans one message - whether there is anything to send is a different judge`() {
|
||||||
|
// N = 0 且三样都空:这条规则照旧回一条(它是同一条规则的空形态)。「有没有东西可发」由
|
||||||
|
// hasSendableContent 判——两个判据各管一件事,谁也不替谁下结论。
|
||||||
|
val plan = imageSendPlan(text = "", images = emptyList(), documents = emptyList())
|
||||||
|
|
||||||
|
assertEquals(1, plan.messages.size)
|
||||||
|
assertTrue(plan.imagesToSend.isEmpty())
|
||||||
|
assertEquals(0, plan.truncatedImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one image stays one message with the text and the documents on it`() {
|
||||||
|
val plan = imageSendPlan(text = "带一张", images = images(1), documents = listOf(doc))
|
||||||
|
|
||||||
|
assertEquals(1, plan.messages.size)
|
||||||
|
assertEquals("带一张", plan.messages.single().text)
|
||||||
|
assertEquals("content://img1", plan.messages.single().imageUri)
|
||||||
|
assertEquals(listOf(doc), plan.messages.single().documents)
|
||||||
|
assertEquals(listOf("content://img1"), plan.imagesToSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two images become two messages and only the first carries the text and the documents`() {
|
||||||
|
val plan = imageSendPlan(text = "两张", images = images(2), documents = listOf(doc))
|
||||||
|
|
||||||
|
assertEquals(2, plan.messages.size)
|
||||||
|
assertEquals(listOf("两张", ""), plan.messages.map { it.text })
|
||||||
|
assertEquals(listOf("content://img1", "content://img2"), plan.imagesToSend)
|
||||||
|
assertEquals(listOf(doc), plan.messages[0].documents)
|
||||||
|
// 附件只随第一条:它本来就挂在**这条**消息上(`chat.documents` 是数组,但不是每条各一份)。
|
||||||
|
assertTrue(plan.messages[1].documents.isEmpty())
|
||||||
|
assertEquals(0, plan.truncatedImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the order the user picked is the order the messages go out`() {
|
||||||
|
val plan = imageSendPlan(text = "四张", images = images(4), documents = emptyList())
|
||||||
|
|
||||||
|
assertEquals(4, plan.messages.size)
|
||||||
|
assertEquals(images(4), plan.imagesToSend)
|
||||||
|
assertEquals("四张", plan.messages.first().text)
|
||||||
|
// 第二条起正文为空:不替用户多说一个字,也不把上一张的配文复制一遍。
|
||||||
|
assertTrue(plan.messages.drop(1).all { it.text.isEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `four images is exactly the cap and nothing is dropped`() {
|
||||||
|
val plan = imageSendPlan(text = "到顶了", images = images(ShareRules.MAX_IMAGES), documents = emptyList())
|
||||||
|
|
||||||
|
assertEquals(ShareRules.MAX_IMAGES, plan.messages.size)
|
||||||
|
assertEquals(ShareRules.MAX_IMAGES, plan.imagesToSend.size)
|
||||||
|
assertEquals(0, plan.truncatedImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `five images keeps the first four and reports one dropped - never silently`() {
|
||||||
|
val plan = imageSendPlan(text = "超了一张", images = images(5), documents = listOf(doc))
|
||||||
|
|
||||||
|
assertEquals(ShareRules.MAX_IMAGES, plan.messages.size)
|
||||||
|
assertEquals(images(4), plan.imagesToSend)
|
||||||
|
assertEquals(1, plan.truncatedImages)
|
||||||
|
// 「只发送前 N 张」里的 N 就是真发出去的那几张(告知那句文案拿它拼)。
|
||||||
|
assertEquals(
|
||||||
|
"分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${ShareRules.MAX_IMAGES} 张",
|
||||||
|
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the over-limit notice says nothing when nothing was dropped`() {
|
||||||
|
// 一张没少时说话才是噪声:这正是「分享一条正常的图什么都不说」的那条边界。
|
||||||
|
assertNull(ShareRules.truncatedImagesNotice(1, 0))
|
||||||
|
assertNull(ShareRules.truncatedImagesNotice(ShareRules.MAX_IMAGES, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ class QueueFullOutcomeTest {
|
|||||||
val state = ChatUiState(
|
val state = ChatUiState(
|
||||||
pending = listOf(pending("local-a", text = "这条", imageUri = "content://img", documents = listOf(draft))),
|
pending = listOf(pending("local-a", text = "这条", imageUri = "content://img", documents = listOf(draft))),
|
||||||
draft = "用户正在输入的下一条",
|
draft = "用户正在输入的下一条",
|
||||||
draftImage = null,
|
draftImages = emptyList(),
|
||||||
draftDocuments = emptyList(),
|
draftDocuments = emptyList(),
|
||||||
error = null,
|
error = null,
|
||||||
)
|
)
|
||||||
@@ -59,7 +59,7 @@ class QueueFullOutcomeTest {
|
|||||||
// 的消息再发一遍(而且会覆盖他正在输入的内容)。
|
// 的消息再发一遍(而且会覆盖他正在输入的内容)。
|
||||||
assertTrue(after.pending.isEmpty())
|
assertTrue(after.pending.isEmpty())
|
||||||
assertEquals("用户正在输入的下一条", after.draft)
|
assertEquals("用户正在输入的下一条", after.draft)
|
||||||
assertNull(after.draftImage)
|
assertTrue(after.draftImages.isEmpty())
|
||||||
assertTrue(after.draftDocuments.isEmpty())
|
assertTrue(after.draftDocuments.isEmpty())
|
||||||
assertNull(after.error)
|
assertNull(after.error)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ class QueueFullOutcomeTest {
|
|||||||
val state = ChatUiState(
|
val state = ChatUiState(
|
||||||
pending = listOf(pending("local-a", text = "被拒的正文", imageUri = "content://img", documents = listOf(draft))),
|
pending = listOf(pending("local-a", text = "被拒的正文", imageUri = "content://img", documents = listOf(draft))),
|
||||||
draft = "",
|
draft = "",
|
||||||
draftImage = null,
|
draftImages = emptyList(),
|
||||||
draftDocuments = emptyList(),
|
draftDocuments = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,7 +90,8 @@ class QueueFullOutcomeTest {
|
|||||||
|
|
||||||
assertTrue(after.pending.isEmpty())
|
assertTrue(after.pending.isEmpty())
|
||||||
assertEquals("被拒的正文", after.draft)
|
assertEquals("被拒的正文", after.draft)
|
||||||
assertEquals("content://img", after.draftImage)
|
// N 张图 = N 条消息之后,失败的那一条只把自己那一张还给输入栏(别的几条各自的图不归它管)。
|
||||||
|
assertEquals(listOf("content://img"), after.draftImages)
|
||||||
assertEquals(listOf(draft), after.draftDocuments)
|
assertEquals(listOf(draft), after.draftDocuments)
|
||||||
assertEquals(QUEUE_FULL_MESSAGE, after.error)
|
assertEquals(QUEUE_FULL_MESSAGE, after.error)
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ class QueueFullOutcomeTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("正在输入", after.draft)
|
assertEquals("正在输入", after.draft)
|
||||||
assertEquals("content://img", after.draftImage)
|
assertEquals(listOf("content://img"), after.draftImages)
|
||||||
assertEquals(QUEUE_FULL_MESSAGE, after.error)
|
assertEquals(QUEUE_FULL_MESSAGE, after.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user