feat(sessions): 接上 D4 未读 —— 抽屉角标 + 已读游标(进入即读、看着就点已读)
后端 v0.37 起 `GET /sessions` 每条会话恒下发 `unreadCount`、`POST /sessions/{id}/read`
可推进**账号级**已读游标(只前进,`MAX(read_at, before)`)、成功后广播 `session_read`,
客户端一直没接。本次补齐:列表能看见未读、看完真的会消、别的端读了本端也消。
- data/api/dto/SessionDto.kt:`SessionSummaryDto.unreadCount`(**默认 0**——
`SessionPreferences` 把整份列表原样持久化,非默认值会让升级后第一次启动整份快照
解码失败、列表先空一下;默认 0 只会让角标少显示一会儿);
新增 `ReadSessionRequest`(`before` 缺席 = 现在,`explicitNulls=false` 保证序列化成
`{}`,正是服务端接受的形状)与 `ReadSessionResponse`
- data/api/SessionsApi.kt + data/repository/SessionRepository.kt:`markRead(sessionId, before)`
—— 不传 0(服务端对 0/负数回 400),null 即「现在」
- data/ws/WsEvent.kt:新增 `SessionRead`(`{type:"session_read", sessionId, data:{readAt}}`)。
会话归属**只在帧壳**上(data 里没有 sessionId);`readAt` 缺席回落 0,而 0 在上层是
「无从判断」——坏帧因此天然退化成什么都不改
- ui/sessions/SessionsViewModel.kt:`SessionItem` 多出 `unreadCount` 与 `lastMessageAt`
(后者只用于游标比对);两条调和规则方向相反且都写成纯函数——
`withReadCursor(readAt)` **有依据**地清(游标只前进,本地最新消息不比游标新才算真已读;
若有更新的消息则保留旧计数,不能把真实未读藏起来),`markRead(id)` 无条件清(用户就在这一行)
- 已读策略:① `select()`(点开 = 进入)以「现在」推进——聊天页紧接着会加载并显示最新一页,
比推本地可能陈旧的 `lastMessage.createdAt` 更诚实,且先就地清零,角标不等网络往返;
② `message_persisted` 且会话是**当前打开**的那一个时,以该消息 `createdAt` 推进
(只标它及其之前,同时落库的更晚消息仍算未读),否则「自己正看着的会话」会攒出角标。
不该标的绝不乱标:别的会话攒未读是正确行为,本端不做「替服务端数未读」(增量会漂移,
抽屉每次打开都会静默重拉,以服务端为准)。失败**静默**——服务端游标是唯一真源
- `session_read` 帧按帧里的会话 id **逐行**定位(刻意不用 `isForSession`:那道闸是给
「一个 VM 只关心一个会话」的 ChatViewModel 用的,本 VM 持有整个列表,按 activeSessionId
过滤会把「别的会话被读了」这一帧丢掉,那正是最该处理的一半)
- 另记一份本地已知游标(只取服务端口径:read 响应的 readAt / 帧的 readAt / 落库消息的
createdAt),在整表替换时把「本地已知没有更新消息」的行压回 0——否则一次竞态中的
GET(快照早于本端刚发起的 read)会把角标闪回来;登出时随快照一起清(游标是账号级的)
- ui/sessions/SessionDrawerContent.kt:角标用 Miuix 自带 `Badge`(错误色药丸 + onError 文字),
且**放在标题行里**而不是 `endActions`:抽屉固定 300dp,卡片与内边距之后只剩约 244dp,
`endActions` 已有三个 40dp 图标(120dp)且被库内 60% 硬上限卡着,再塞角标会把图标挤到裁剪。
改成标题行的 `[标题][角标]` 后角标不占额外列宽;标题/摘要同时收紧为 `maxLines = 1` +
省略号(长标题不再在约 116dp 里折成两三行把卡片撑高);`99+` 收敛、`0` 不画
测试:305 全绿(272 → +33):角标文本(0/负数不画、99 精确、100 与 1234 收敛 `99+`)、
两条调和规则的各种边界(游标等于/新于/旧于最新消息、无消息会话、坏帧 readAt=0、
按会话逐行定位、遮罩只压记过游标的行)、`toItem` 映射、`session_read` 帧解码
(生产者逐字取自后端 `gateway/session_read.go:broadcastSessionRead`)、DTO 真载荷
(`GET /sessions` 抓包里主会话 unreadCount=6、次级=0)与**旧快照无该键仍解得开**、
read 请求体序列化(`{}` / `{"before":…}`)。
未验证(无模拟器,也不许碰 adb,只有构建 + 单测):
- 角标与标题的**实际观感**没截图。宽度是按 Miuix 0.9.3 源码算的(抽屉 300dp − 卡片边距 24
− 组件内边距 32 = 244dp;endActions 120dp < 60% 上限,标题列约 116dp,角标约 16~32dp)。
LazyColumn 里角标与标题的具体落位(紧贴标题还是行尾)取决于 Row 的权重分配细节,两种都成立、
都不遮蔽文字,但真机上可能想微调间距
- 「进入即读」在 UI 上没端到端跑过:POST /sessions/{id}/read 是否 200、`session_read` 帧
是否真的清掉另一端的角标,只有单测与契约(后端源码)支撑,没有真机往返
- 已知近似:判断「正在看这个会话」只用 `activeSessionId`,而它是**活动页**的会话 id——
用户切到设置 / 管理页时 ChatScreen 已卸载,此时落库的消息也会被标记已读(他会回来看到,
但角标已经消了)。信息不足,如实记在这里
架构代价(需主人裁决):`session_read` / `message_persisted` 只在 WS 上,而 `AppContainer`
只提供 `createChatSocket()`(每次新建,**没有进程级共享连接**),`ChatSocket` / `ChatViewModel`
/ `AppContainer` 都在别人的改动范围里 —— 本 VM 因此自持一条连接(登录后连、登出断开、
销毁断开)。这是既有遗留问题「每 VM 一条 socket、旧 socket 不关」之上再加一条;服务端支持
同账号多连接(多端本来如此),收不到帧也只是退回「下次 GET /sessions 收敛」。共享一条
app 级连接是更好的终局,但那要动 AppContainer / ChatSocket。
This commit is contained in:
1 parent
feeb49ebeb
commit
f413f8db55
9 files changed
+754
-11
No files matched your search
@@ -3,6 +3,8 @@ package top.yeij.cyrene.data.api
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
@@ -60,4 +62,17 @@ interface SessionsApi {
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("platform") platform: String? = null,
|
||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
||||
|
||||
/**
|
||||
* 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。
|
||||
*
|
||||
* `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。
|
||||
* 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、
|
||||
* 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。
|
||||
*/
|
||||
@POST("api/v1/sessions/{id}/read")
|
||||
suspend fun markRead(
|
||||
@Path("id") sessionId: String,
|
||||
@Body body: ReadSessionRequest,
|
||||
): Response<ApiEnvelope<ReadSessionResponse>>
|
||||
}
|
||||
@@ -21,6 +21,16 @@ data class SessionSummaryDto(
|
||||
val pinned: Boolean = false,
|
||||
/** 会话还没有消息时**整个键缺席**。 */
|
||||
val lastMessage: SessionLastMessageDto? = null,
|
||||
/**
|
||||
* 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数,
|
||||
* `0` = 无未读。
|
||||
*
|
||||
* 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences]
|
||||
* 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败,
|
||||
* 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。
|
||||
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
|
||||
*/
|
||||
val unreadCount: Int = 0,
|
||||
) {
|
||||
val isMain: Boolean
|
||||
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
|
||||
@@ -82,3 +92,26 @@ data class UpdateSessionResponse(
|
||||
data class DeleteSessionResponse(
|
||||
val deleted: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。
|
||||
*
|
||||
* - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false`
|
||||
* 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象
|
||||
* (`api-reference §3.6b`:**空 body / `{}` = 现在**)。
|
||||
* - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到
|
||||
* 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。
|
||||
* - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。
|
||||
*/
|
||||
@Serializable
|
||||
data class ReadSessionRequest(
|
||||
val before: Long? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"<id>","readAt":<毫秒>}}`。 */
|
||||
@Serializable
|
||||
data class ReadSessionResponse(
|
||||
val sessionId: String = "",
|
||||
/** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */
|
||||
val readAt: Long = 0L,
|
||||
)
|
||||
@@ -2,6 +2,8 @@ package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
@@ -49,4 +51,18 @@ class SessionRepository(
|
||||
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。
|
||||
*
|
||||
* @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数
|
||||
* 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。
|
||||
*
|
||||
* 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用——
|
||||
* 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。
|
||||
*/
|
||||
suspend fun markRead(sessionId: String, before: Long? = null): Result<ReadSessionResponse> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) }
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,26 @@ sealed class WsEvent {
|
||||
override val type: String get() = "chat_duplicate"
|
||||
}
|
||||
|
||||
/**
|
||||
* 已读游标推进(D4,契约 v0.37,api-client §3.9):
|
||||
* `{type:"session_read", sessionId, data:{readAt}}`——**载荷里没有会话 id**,
|
||||
* 会话归属**只**来自帧壳(服务端 `POST /sessions/{id}/read` 成功后广播)。
|
||||
*
|
||||
* 它是那 7 个「不含内容的状态机信号」之一(发给**所有**已认证连接,含 `viewer`),
|
||||
* 与 `message_persisted` 同一条全连接广播路径,故同样要按 [isForSession] 收敛。
|
||||
* [readAt] 是**推进后**的游标(毫秒,与该会话消息的 `createdAt` 同单位);
|
||||
* data 缺席时回落 `0`——游标实际不可能为 0,于是「坏帧」天然退化成**什么也不改**
|
||||
* (见 `SessionItem.withReadCursor`:只有 `lastMessageAt <= readAt` 才清零)。
|
||||
*/
|
||||
data class SessionRead(
|
||||
override val sessionId: String,
|
||||
val readAt: Long,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "session_read"
|
||||
}
|
||||
|
||||
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
|
||||
data class MessagePersisted(
|
||||
val message: HistoryMessageDto?,
|
||||
@@ -459,6 +479,9 @@ sealed class WsEvent {
|
||||
seq,
|
||||
)
|
||||
}
|
||||
// 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。
|
||||
"session_read" -> decode<SessionReadData>(data)
|
||||
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
|
||||
"message_persisted" -> decode<PersistedData>(data)
|
||||
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
|
||||
"channel_persisted" -> decode<PersistedData>(data)
|
||||
@@ -567,6 +590,10 @@ private data class ChatDuplicateData(
|
||||
val messageId: String? = null,
|
||||
)
|
||||
|
||||
/** `session_read` 载荷(api-client §3.9):`{readAt}`——毫秒游标,**不带**会话 id。 */
|
||||
@Serializable
|
||||
private data class SessionReadData(val readAt: Long = 0L)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedData(val message: HistoryMessageDto? = null)
|
||||
|
||||
|
||||
@@ -21,10 +21,16 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.Badge
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
@@ -157,6 +163,19 @@ fun ColumnScope.SessionDrawerContent(
|
||||
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
|
||||
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
|
||||
* 子级会先吃掉按下事件,长按就永远不会触发)。
|
||||
*
|
||||
* ## 为什么不用 `BasicComponent` 的 `title` / `summary` 参数
|
||||
* 那两个参数只收 `String`,塞不下未读角标;而角标**不能**放进 `endActions`——
|
||||
* 抽屉固定 300dp,卡片 + 内边距之后只剩约 244dp,而 `endActions` 里已经有三个 40dp 的
|
||||
* 图标按钮(120dp),`BasicComponent` 又把它内部可用宽度的 60% 作为 `endActions` 的硬上限:
|
||||
* 再塞一个角标会把图标挤到裁剪(Row 不换行),标题列也被压到约 95dp。
|
||||
* 所以这里改用「内容重载」自己搭标题行:`[标题][角标]` 同行,角标紧挨标题、不占额外列宽。
|
||||
*
|
||||
* 代价是标题 / 摘要的字号、字重、颜色要自己写,取值与库内实现(`Component.kt`)逐字一致
|
||||
* (`headline1.fontSize` + `Medium` + `BasicComponentDefaults.titleColor()`;
|
||||
* `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**:
|
||||
* 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的
|
||||
* 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
@@ -167,11 +186,13 @@ private fun SessionRow(
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val titleColor = BasicComponentDefaults.titleColor().color
|
||||
val summaryColor = BasicComponentDefaults.summaryColor().color
|
||||
val badge = unreadBadgeText(session.unreadCount)
|
||||
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
|
||||
title = session.title,
|
||||
summary = session.lastMessage,
|
||||
endActions = {
|
||||
if (active) {
|
||||
Text(
|
||||
@@ -202,10 +223,53 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = session.title,
|
||||
// 标题是这一行唯一的权重子项,`fill = false` 让短标题只占自己需要的宽度。
|
||||
// 真正保证角标不被挤掉的是 Row 的测量顺序:**非权重子项(角标与它前面的间距)
|
||||
// 先量**,权重只分配剩下的空间,标题再在这块空间里省略——
|
||||
// 所以 `99+` 永远进得来,标题也不会被压到完全看不见。
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
fontSize = MiuixTheme.textStyles.headline1.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = titleColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (badge != null) {
|
||||
Spacer(Modifier.width(UNREAD_BADGE_GAP))
|
||||
// Miuix 自带角标:错误色药丸 + `onError` 文字 + 16dp 下限高度,
|
||||
// 在 `surfaceContainer` 卡片上对比度足够;`99+` 会自然撑成药丸而不会溢出。
|
||||
Badge(
|
||||
modifier = Modifier.clearAndSetSemantics {
|
||||
contentDescription = "未读 $badge"
|
||||
},
|
||||
) {
|
||||
Text(badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.lastMessage?.let { preview ->
|
||||
Text(
|
||||
text = preview,
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = summaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 标题与角标之间的间距:太小会粘成一个词,太大又脱离标题。 */
|
||||
private val UNREAD_BADGE_GAP = 6.dp
|
||||
|
||||
/** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
|
||||
@@ -12,6 +12,8 @@ import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
data class SessionItem(
|
||||
val id: String,
|
||||
@@ -20,6 +22,13 @@ data class SessionItem(
|
||||
val pinned: Boolean,
|
||||
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
|
||||
val lastMessage: String?,
|
||||
/**
|
||||
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
|
||||
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
|
||||
*/
|
||||
val lastMessageAt: Long? = null,
|
||||
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
|
||||
val unreadCount: Int = 0,
|
||||
)
|
||||
|
||||
data class SessionsUiState(
|
||||
@@ -37,6 +46,21 @@ data class SessionsUiState(
|
||||
* 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
|
||||
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
|
||||
* 客户端不重排。
|
||||
*
|
||||
* ## 未读(D4)
|
||||
* 未读条数来自服务端(`GET /sessions` 的 `unreadCount`,恒下发),本类只在三处改它:
|
||||
* 列表整表替换(以服务端为准)、**进入会话**、以及**正在看的会话有新消息落库**——后两处
|
||||
* 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。
|
||||
* 反向同步(别的端读了)靠下行 `session_read` 帧。
|
||||
*
|
||||
* ## 为什么本类自己开一条 WS 连接
|
||||
* `session_read` / `message_persisted` 都在 WS 上,而 `AppContainer` 只提供
|
||||
* `createChatSocket()`(**每次调用新建**,没有进程级共享连接),`ChatSocket` / `ChatViewModel`
|
||||
* 又都在别的改动范围里。要在这里收帧,唯一不改别人的办法就是自持一条连接。
|
||||
* 代价如实说:这是本应用**已知遗留问题**(「每 VM 一条 socket、旧 socket 不关」,见
|
||||
* `devdocs/05`)之上再加一条——服务端对同一账号的多连接是支持的(多端场景本来如此),
|
||||
* 而且本类只关心两个帧类型,收不到也不会让功能失效(`GET /sessions` 会收敛)。
|
||||
* 生命周期:登录后连接、登出断开、VM 销毁断开([onCleared])。
|
||||
*/
|
||||
class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
@@ -45,6 +69,21 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val prefs = container.sessionPreferences
|
||||
|
||||
/**
|
||||
* 本端**已知的已读游标**(会话 id → 毫秒),写入值一律取自**服务端口径**:
|
||||
* - `POST /sessions/{id}/read` 响应里的 `readAt`(权威);
|
||||
* - 下行 `session_read` 帧的 `readAt`(可能是别的端推的);
|
||||
* - 本端对某条落库消息发起的推进,先用该消息的 `createdAt` 占位——它也是服务端时间,
|
||||
* 所以不存在设备时钟偏差。
|
||||
*
|
||||
* 唯一用途是 [withReadCursors]:整表替换(REST 快照)时,把「本地已知没有比游标更新的
|
||||
* 消息」的那些行的未读压回 0。不做这件事的话,刚在别处读完、或本端刚标记过的会话,
|
||||
* 会因为一次竞态中的 GET 把角标又闪回来。
|
||||
*/
|
||||
private val readCursors = mutableMapOf<String, Long>()
|
||||
|
||||
private val socket: ChatSocket = container.createChatSocket()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val fingerprint = fingerprint()
|
||||
@@ -52,7 +91,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
val snapshot = cached.snapshotFor(fingerprint)
|
||||
_state.update {
|
||||
it.copy(
|
||||
sessions = snapshot.map { dto -> dto.toItem() },
|
||||
sessions = snapshot.map { dto -> dto.toItem() }.withReadCursors(readCursors),
|
||||
activeSessionId = if (snapshot.isEmpty()) {
|
||||
SessionSummaryDto.MAIN_SESSION_ID
|
||||
} else {
|
||||
@@ -65,15 +104,34 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
refresh()
|
||||
}
|
||||
// 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)——
|
||||
// 否则换账号后第一帧会闪一份上个账号的会话列表。
|
||||
// 否则换账号后第一帧会闪一份上个账号的会话列表。已读游标同理:它是**账号级**的
|
||||
// (服务端单一游标),留着会让新账号的会话被旧账号的游标错误地清成已读。
|
||||
viewModelScope.launch {
|
||||
container.authManager.state.collect { auth ->
|
||||
if (auth == SessionState.LoggedOut) {
|
||||
prefs.clear()
|
||||
_state.value = SessionsUiState()
|
||||
when (auth) {
|
||||
SessionState.LoggedOut -> {
|
||||
socket.close()
|
||||
readCursors.clear()
|
||||
prefs.clear()
|
||||
_state.value = SessionsUiState()
|
||||
}
|
||||
|
||||
// 连接只在登录后建立:Socket 的令牌 / 地址都是每次重连现读的,
|
||||
// connect() 幂等(已连接时直接返回)。本 VM 存活期跨越登出 → 重登,
|
||||
// 所以「登出断开、重登接上」必须在这里显式表达。
|
||||
is SessionState.LoggedIn -> socket.connect()
|
||||
|
||||
SessionState.Unknown -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未读的两个下行来源都在这里收敛;见 [onEvent]。
|
||||
viewModelScope.launch { socket.events.collect(::onEvent) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
socket.close()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +153,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
if (active != _state.value.activeSessionId) prefs.setActive(active)
|
||||
_state.update {
|
||||
it.copy(
|
||||
sessions = list.map { dto -> dto.toItem() },
|
||||
// 未读以服务端为准,但先过一遍本地已知游标:一次竞态中的 GET
|
||||
// (快照早于本端刚发起的 read)不该把角标闪回来。
|
||||
sessions = list.map { dto -> dto.toItem() }.withReadCursors(readCursors),
|
||||
activeSessionId = active,
|
||||
loading = false,
|
||||
fromCache = false,
|
||||
@@ -116,9 +176,23 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 / 进入会话。
|
||||
*
|
||||
* **进入即已读**(D4):用户点开这个会话,聊天页紧接着会加载并显示它的最新一页,
|
||||
* 所以立刻把游标推到「现在」(`before` 缺席 = 服务端取现在)——这比推一个本地
|
||||
* `lastMessage.createdAt` 更诚实:本地的列表可能已经陈旧,而聊天页看到的一定是最新的。
|
||||
* 先就地清零(不等网络往返,角标立刻消失),再用响应里的权威游标收敛。
|
||||
*
|
||||
* `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后,
|
||||
* 屏幕上(即将)显示的就是这个会话。
|
||||
*/
|
||||
fun select(sessionId: String) {
|
||||
_state.update { it.copy(activeSessionId = sessionId) }
|
||||
_state.update {
|
||||
it.copy(activeSessionId = sessionId, sessions = it.sessions.markRead(sessionId))
|
||||
}
|
||||
viewModelScope.launch { prefs.setActive(sessionId) }
|
||||
pushReadCursor(sessionId)
|
||||
}
|
||||
|
||||
fun createSession() {
|
||||
@@ -184,6 +258,76 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
fun dismissError() = _state.update { it.copy(error = null) }
|
||||
|
||||
/**
|
||||
* 下行帧收敛(未读相关只有两类)。
|
||||
*
|
||||
* **这里刻意不用 [top.yeij.cyrene.data.ws.isForSession] 过滤**:那道闸是给「一个 VM 只关心
|
||||
* 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId
|
||||
* 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id
|
||||
* **逐行定位**([applyReadCursor],影响面严格限制在那一行)。
|
||||
*/
|
||||
private fun onEvent(event: WsEvent) {
|
||||
when (event) {
|
||||
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
|
||||
is WsEvent.SessionRead -> onSessionRead(event)
|
||||
// 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标,
|
||||
// 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。
|
||||
is WsEvent.MessagePersisted -> onMessagePersisted(event)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSessionRead(event: WsEvent.SessionRead) {
|
||||
if (event.sessionId.isBlank()) return
|
||||
recordReadCursor(event.sessionId, event.readAt)
|
||||
_state.update { it.copy(sessions = it.sessions.applyReadCursor(event.sessionId, event.readAt)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* `message_persisted`:只有**当前打开的那个会话**才推进游标。
|
||||
*
|
||||
* 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数——
|
||||
* 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移,
|
||||
* 而抽屉每次打开都会静默重拉,以服务端为准)。
|
||||
*/
|
||||
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
|
||||
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||
if (sessionId != _state.value.activeSessionId) return
|
||||
val message = event.message ?: return
|
||||
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成
|
||||
// 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。
|
||||
val before = message.createdAt.takeIf { it > 0 }
|
||||
before?.let { recordReadCursor(sessionId, it) }
|
||||
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
|
||||
pushReadCursor(sessionId, before)
|
||||
}
|
||||
|
||||
/**
|
||||
* 推进已读游标(D4)。**失败静默**:这是一次后台的「顺手记一下」,不是用户发起的操作——
|
||||
* 弹错误横幅会为一件用户没要求、也不影响使用的失败打扰他;而且服务端游标才是唯一真源,
|
||||
* 漏标一次会在下一次 `GET /sessions` 暴露出来、再被 [select] / [onMessagePersisted] 纠正。
|
||||
* 请求本身幂等(服务端 `MAX(read_at, before)`),重复发不会把已读退回未读。
|
||||
*
|
||||
* @param before null = 现在。**绝不能传 0**:服务端把 `0` / 负数当非法参数回 400。
|
||||
*/
|
||||
private fun pushReadCursor(sessionId: String, before: Long? = null) {
|
||||
viewModelScope.launch {
|
||||
container.sessionRepository.markRead(sessionId, before)
|
||||
.onSuccess { response ->
|
||||
if (response.readAt > 0) recordReadCursor(sessionId, response.readAt)
|
||||
_state.update {
|
||||
it.copy(sessions = it.sessions.applyReadCursor(sessionId, response.readAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 记下已知游标:只接受正数(坏帧的 `readAt = 0` 不能污染成「读到纪元」)。 */
|
||||
private fun recordReadCursor(sessionId: String, readAt: Long) {
|
||||
if (readAt <= 0) return
|
||||
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
|
||||
}
|
||||
|
||||
private fun fingerprint(): String =
|
||||
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
|
||||
}
|
||||
@@ -194,8 +338,57 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
|
||||
isMain = isMain,
|
||||
pinned = pinned,
|
||||
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
|
||||
lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 },
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
|
||||
/**
|
||||
* 未读角标的文本(纯函数)。`null` = **不显示**角标。
|
||||
*
|
||||
* `0` 与负数都不显示(服务端只会给非负整数,负数当数据错误处理,不画一个「-1」)。
|
||||
* 超过 [UNREAD_BADGE_MAX] 收敛为 `99+`:三位数的角标在 300dp 的抽屉里会把标题挤没,
|
||||
* 而三位数以上的精确值对「有多少条没看」几乎没有信息增量。
|
||||
*/
|
||||
internal fun unreadBadgeText(count: Int): String? = when {
|
||||
count <= 0 -> null
|
||||
count > UNREAD_BADGE_MAX -> "$UNREAD_BADGE_MAX+"
|
||||
else -> count.toString()
|
||||
}
|
||||
|
||||
/** 角标显示的未读上限;超过就显示 `99+`。 */
|
||||
internal const val UNREAD_BADGE_MAX = 99
|
||||
|
||||
/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */
|
||||
internal fun List<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
|
||||
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
|
||||
|
||||
/** 把某一行的未读清零(`session_read` 帧、或本端刚标记过)。 */
|
||||
internal fun List<SessionItem>.applyReadCursor(sessionId: String, readAt: Long): List<SessionItem> =
|
||||
map { if (it.id == sessionId) it.withReadCursor(readAt) else it }
|
||||
|
||||
/**
|
||||
* 用户**正在看**这个会话(或本端刚为它推进过游标)时,无条件清掉它的未读。
|
||||
* 与 [withReadCursor] 的区别:这里不需要游标依据——调用方已经知道「这一行就是屏幕上的那一行」
|
||||
* (进入会话、正在看的会话落了新消息)。
|
||||
*/
|
||||
internal fun List<SessionItem>.markRead(sessionId: String): List<SessionItem> =
|
||||
map { if (it.id == sessionId) it.copy(unreadCount = 0) else it }
|
||||
|
||||
/**
|
||||
* 按已读游标清未读(纯函数):**只在「本地已知的最新一条消息不比游标新」时清零**。
|
||||
*
|
||||
* 为什么不能一律清零(api-client §3.9 说「直接清零也不会错」):那边的假设是「本地没有更完整的
|
||||
* 数据」。本端有——`lastMessage.createdAt` 就是本地已知的最新消息时间。若它**晚于**游标,
|
||||
* 说明确实还有未读(游标只前进,这只能是读完以后新到的消息),此时清零就是**把真实未读藏起来**。
|
||||
* 保留旧计数更接近真相:精确条数本地算不出来(没有逐条时间线),等下次 `GET /sessions` 收敛。
|
||||
*
|
||||
* 会话没有消息([SessionItem.lastMessageAt] 为 null)时一定没有未读,清零。
|
||||
*/
|
||||
internal fun SessionItem.withReadCursor(readAt: Long): SessionItem {
|
||||
val newest = lastMessageAt
|
||||
return if (newest != null && newest > readAt) this else copy(unreadCount = 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
|
||||
*
|
||||
|
||||
@@ -22,7 +22,7 @@ import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
* **置顶的次级会话合法地排在主会话前面**(不能假设下标 0 是主会话);
|
||||
* - 还没有用户消息的会话 `title` 键**整个缺席**(自动命名只在首条用户消息时写入);
|
||||
* - 没有任何消息的会话 `lastMessage` 键整个缺席;
|
||||
* - `pinned` 恒下发(主会话上是显式 `false`),`unreadCount` 无关键照收。
|
||||
* - `pinned` 恒下发(主会话上是显式 `false`)、`unreadCount`(D4)恒下发——两者都必须读进模型。
|
||||
*/
|
||||
class SessionDtoRealPayloadTest {
|
||||
|
||||
@@ -164,4 +164,69 @@ class SessionDtoRealPayloadTest {
|
||||
// 服务端对 `{}` 回 400 `title or pinned is required`;调用方必须保证至少带一个字段。
|
||||
assertEquals("{}", body)
|
||||
}
|
||||
|
||||
// —— D4 未读 / 已读游标(契约 v0.37) ——
|
||||
|
||||
@Test
|
||||
fun `the unread count of the live payload is read off the wire`() {
|
||||
val sessions = decode(realSessionList)
|
||||
|
||||
// 这份线上载荷里两个值都有:已读过的次级会话是显式 0,主会话有 6 条没看。
|
||||
assertEquals(0, sessions[0].unreadCount)
|
||||
assertEquals(6, sessions[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session row without the unread key decodes to zero`() {
|
||||
// 旧快照:本版之前写下的一份(那时 DTO 里还没有这个字段),升级后必须照样解得开——
|
||||
// 否则第一次启动整份快照解码失败、列表先空一下,正是最不该出现的表现。
|
||||
val legacy = """{"id":"web","type":"main","createdAt":1788724031196,"pinned":false}"""
|
||||
|
||||
assertEquals(0, json.decodeFromString(SessionSummaryDto.serializer(), legacy).unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snapshot round trip keeps the unread count`() {
|
||||
// `SessionPreferences` 存快照用的就是这份 Json + 这个 ListSerializer。
|
||||
val before = decode(realSessionList)
|
||||
|
||||
val restored = json.decodeFromString(sessionList, json.encodeToString(sessionList, before))
|
||||
|
||||
assertEquals(before, restored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read request without a timestamp omits the key entirely`() {
|
||||
val body = json.encodeToString(ReadSessionRequest.serializer(), ReadSessionRequest())
|
||||
|
||||
// 「缺省 = 现在」:服务端接受的正是空对象 `{}`;而 `before` 为 0 / 负数 / 非数字一律 400,
|
||||
// 所以「没有时间戳」只能表达成**整键缺席**,绝不能是 `{"before":null}` 或 `{"before":0}`。
|
||||
assertEquals("{}", body)
|
||||
assertFalse(body, body.contains("before"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read request with a timestamp sends it as millis`() {
|
||||
val body = json.encodeToString(
|
||||
ReadSessionRequest.serializer(),
|
||||
ReadSessionRequest(before = 1_753_000_000_000L),
|
||||
)
|
||||
|
||||
assertEquals("""{"before":1753000000000}""", body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the documented read response decodes to the advanced cursor`() {
|
||||
// 形状取自 api-reference §3.6b 的 curl 示例,与后端 `handleMarkSessionRead` 的
|
||||
// `writeJSON`(`{"data": …}`)一致。**这是文档示例,不是线上抓取**(不调线上端点)。
|
||||
val response = json.decodeFromString(
|
||||
ApiEnvelope.serializer(ReadSessionResponse.serializer()),
|
||||
"""{"data":{"sessionId":"web","readAt":1753000000000}}""",
|
||||
)
|
||||
|
||||
val data = requireNotNull(response.data)
|
||||
|
||||
assertEquals("web", data.sessionId)
|
||||
assertEquals(1_753_000_000_000L, data.readAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* D4 下行帧 `session_read` 的解码(契约 v0.37,api-client §3.9)。
|
||||
*
|
||||
* 帧形状不是手写猜的,取自**后端生产者逐字**:
|
||||
* `internal/services/gateway/session_read.go:broadcastSessionRead` ——
|
||||
*
|
||||
* ```go
|
||||
* h.broadcast(wsEvent{Type: "session_read", SessionID: sessionID, Data: map[string]any{"readAt": readAt}})
|
||||
* ```
|
||||
*
|
||||
* 三个要点都在这里钉死:
|
||||
* 1. 会话归属**只在帧壳**上(`data` 里没有 sessionId),游标是 `data.readAt`(毫秒整数);
|
||||
* 2. 它是那 7 个「状态机信号」之一,发给所有已认证连接,因此同样要按 [isForSession] 收敛;
|
||||
* 3. 坏帧(缺 data)要安全降级:`readAt` 解成 0,而上层把 0 当「无从判断」——正好什么都不改。
|
||||
*/
|
||||
class WsEventSessionReadTest {
|
||||
|
||||
private fun parse(raw: String): WsEvent =
|
||||
requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" }
|
||||
|
||||
@Test
|
||||
fun `the produced frame decodes with its session and cursor`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1753000000000}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("session_read", ev.type)
|
||||
assertEquals("web", ev.sessionId)
|
||||
assertEquals(1_753_000_000_000L, ev.readAt)
|
||||
// 状态机信号不带轮次 / 序号:hub 直接广播,不经过回合。
|
||||
assertNull(ev.turnId)
|
||||
assertNull(ev.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the frame carries the session of a sub session too`() {
|
||||
// 读的是哪个会话就广播哪个——不是「总是主会话」。
|
||||
val ev = parse(
|
||||
"""{"type":"session_read","sessionId":"web:299bbf0b","data":{"readAt":1791090628688}}""",
|
||||
) as WsEvent.SessionRead
|
||||
|
||||
assertEquals("web:299bbf0b", ev.sessionId)
|
||||
assertEquals(1_791_090_628_688L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without data degrades to a zero cursor instead of failing`() {
|
||||
// 上层把 readAt = 0 当「无从判断」:有消息的行原样保留计数,绝不会误清成已读。
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web"}""") as WsEvent.SessionRead
|
||||
|
||||
assertEquals(0L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame whose shell omits the session gets a blank session, never a null`() {
|
||||
// 没有会话 id 的这一帧无法归属任何一行;上层据此直接跳过(而不是当成「属于当前会话」)。
|
||||
val ev = parse("""{"type":"session_read","data":{"readAt":1753000000000}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null session on the shell also decodes to a blank session`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":null,"data":{"readAt":1}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown keys inside data are ignored`() {
|
||||
// 后端将来在 data 里加字段不能被这一层挡掉(`ignoreUnknownKeys`)。
|
||||
val ev = parse(
|
||||
"""{"type":"session_read","sessionId":"web","data":{"readAt":42,"unread":0}}""",
|
||||
) as WsEvent.SessionRead
|
||||
|
||||
assertEquals(42L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read frame follows the same per session rule as the other frames`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1}}""")
|
||||
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertFalse(ev.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank session on a read frame is unknown, so every session accepts it`() {
|
||||
val ev = parse("""{"type":"session_read","data":{"readAt":1}}""")
|
||||
|
||||
// 空 = 「无法判定」而不是「不属于本会话」——与其它帧同一口径。
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertTrue(ev.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sub session id on the shell is kept verbatim`() {
|
||||
// 帧壳上的 sessionId 只是字符串,本层不做格式裁决(`web:<uuid>` / `sess-xxx` 都合法)。
|
||||
val ev = parse("""{"type":"session_read","sessionId":"sess-9c3","data":{"readAt":7}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("sess-9c3", ev.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.SessionLastMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
/**
|
||||
* D4 未读的两块纯逻辑:角标文本,以及两条**方向相反**的本地调和规则。
|
||||
*
|
||||
* 调和规则为什么要分两条:
|
||||
* - [withReadCursor](`session_read` 帧 / 本地已知游标)是**有依据**的清零——游标只前进,
|
||||
* 所以「本地最新一条消息不比游标新」就等于没有未读;反过来若本地明明有更新的消息
|
||||
* (读完以后新到的),清成 0 就是把真实未读藏起来,必须保留旧计数。
|
||||
* - [markRead] 是**无条件**清零——调用方(`select()` / 正在看的会话落库)已经知道
|
||||
* 这一行就是屏幕上的那一行,不需要游标依据。
|
||||
*/
|
||||
class SessionUnreadTest {
|
||||
|
||||
private fun item(
|
||||
id: String = "web",
|
||||
lastMessageAt: Long? = null,
|
||||
unreadCount: Int = 0,
|
||||
) = SessionItem(
|
||||
id = id,
|
||||
title = "标题",
|
||||
isMain = id == "web",
|
||||
pinned = false,
|
||||
lastMessage = if (lastMessageAt == null) null else "预览",
|
||||
lastMessageAt = lastMessageAt,
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
|
||||
// —— 角标文本 ——
|
||||
|
||||
@Test
|
||||
fun `no unread means no badge at all`() {
|
||||
assertNull(unreadBadgeText(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative count is a data error, not a badge`() {
|
||||
// 服务端恒下发非负整数;真出现负数说明数据坏了,绝不画一个「-1」出来。
|
||||
assertNull(unreadBadgeText(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small counts are shown verbatim`() {
|
||||
assertEquals("1", unreadBadgeText(1))
|
||||
assertEquals("9", unreadBadgeText(9))
|
||||
assertEquals("10", unreadBadgeText(10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the boundary at 99 is still exact and 100 overflows`() {
|
||||
assertEquals("99", unreadBadgeText(99))
|
||||
// 三位数会把 300dp 抽屉里的标题挤没,而精确值几乎没有信息增量。
|
||||
assertEquals("99+", unreadBadgeText(100))
|
||||
assertEquals("99+", unreadBadgeText(1234))
|
||||
}
|
||||
|
||||
// —— 按游标清零(有依据) ——
|
||||
|
||||
@Test
|
||||
fun `a cursor newer than the last message clears the badge`() {
|
||||
val cleared = item(lastMessageAt = 1_000L, unreadCount = 3).withReadCursor(2_000L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor exactly on the last message clears the badge`() {
|
||||
// 服务端口径是 `createdAt <= readAt` 算已读、只数 `createdAt > readAt` 的,所以相等即已读。
|
||||
val cleared = item(lastMessageAt = 1_000L, unreadCount = 1).withReadCursor(1_000L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message newer than the cursor keeps the badge`() {
|
||||
// 本地已知有比游标更新的消息 ⇒ 真的还有未读(游标只前进,这只能是读完以后新到的)。
|
||||
// 精确条数本地算不出来(没有逐条时间线),保留旧计数比清零更接近真相。
|
||||
val kept = item(lastMessageAt = 3_000L, unreadCount = 2).withReadCursor(1_000L)
|
||||
|
||||
assertEquals(2, kept.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session without any message has nothing to be unread`() {
|
||||
val cleared = item(lastMessageAt = null, unreadCount = 1).withReadCursor(0L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad frame with readAt zero changes nothing it cannot judge`() {
|
||||
// 帧里没有 readAt 时解出来是 0(真实游标不可能是 0):有消息的行**原样保留**。
|
||||
assertEquals(5, item(lastMessageAt = 10L, unreadCount = 5).withReadCursor(0L).unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero count stays zero`() {
|
||||
assertEquals(0, item(lastMessageAt = 10L, unreadCount = 0).withReadCursor(1L).unreadCount)
|
||||
}
|
||||
|
||||
// —— 整表替换时的游标遮罩 ——
|
||||
|
||||
@Test
|
||||
fun `only the rows with a known cursor are masked`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 1_000L, unreadCount = 4),
|
||||
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 7),
|
||||
)
|
||||
|
||||
val masked = sessions.withReadCursors(mapOf("web" to 2_000L))
|
||||
|
||||
assertEquals(0, masked[0].unreadCount)
|
||||
// 没记过游标的会话保持服务端的值——遮罩只压「本地已知已读」的行。
|
||||
assertEquals(7, masked[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor older than the row keeps the server count`() {
|
||||
// 服务端说 4 条未读,而本地游标只推到「上一条消息」:这不是矛盾,是真的还有未读。
|
||||
val masked = listOf(item(id = "web", lastMessageAt = 5_000L, unreadCount = 4))
|
||||
.withReadCursors(mapOf("web" to 1_000L))
|
||||
|
||||
assertEquals(4, masked[0].unreadCount)
|
||||
}
|
||||
|
||||
// —— 按会话定位(帧里带的是**那个**会话的 id,不是当前会话) ——
|
||||
|
||||
@Test
|
||||
fun `a read frame only touches its own row`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 1_000L, unreadCount = 2),
|
||||
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 5),
|
||||
)
|
||||
|
||||
val updated = sessions.applyReadCursor("web:other", 2_000L)
|
||||
|
||||
// 别的端读的是 web:other——这正是本端要处理的一半,不能按 activeSessionId 过滤掉。
|
||||
assertEquals(2, updated[0].unreadCount)
|
||||
assertEquals(0, updated[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read frame for an unknown session is a no-op`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 1_000L, unreadCount = 2))
|
||||
|
||||
assertEquals(sessions, sessions.applyReadCursor("web:gone", 9_999L))
|
||||
}
|
||||
|
||||
// —— 无条件清零(本端刚看到) ——
|
||||
|
||||
@Test
|
||||
fun `marking the active session read clears it regardless of the cursor`() {
|
||||
// 用户就在这个会话里:即使本地已知有更新的消息,也必须清(那正是他屏幕上那条)。
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 9_999L, unreadCount = 3),
|
||||
item(id = "web:other", lastMessageAt = 9_999L, unreadCount = 3),
|
||||
)
|
||||
|
||||
val updated = sessions.markRead("web")
|
||||
|
||||
assertEquals(0, updated[0].unreadCount)
|
||||
assertEquals(3, updated[1].unreadCount)
|
||||
}
|
||||
|
||||
// —— DTO → 行模型的映射 ——
|
||||
|
||||
@Test
|
||||
fun `toItem carries the unread count and the last message time`() {
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
title = "临时小事",
|
||||
createdAt = 1L,
|
||||
unreadCount = 6,
|
||||
lastMessage = SessionLastMessageDto(role = "assistant", content = "在呢", createdAt = 1_791_090_628_688L),
|
||||
)
|
||||
|
||||
val item = dto.toItem()
|
||||
|
||||
assertEquals(6, item.unreadCount)
|
||||
assertEquals(1_791_090_628_688L, item.lastMessageAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toItem without a last message has no time and no unread`() {
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
createdAt = 1L,
|
||||
)
|
||||
|
||||
val item = dto.toItem()
|
||||
|
||||
assertNull(item.lastMessageAt)
|
||||
assertEquals(0, item.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unread count without a last message still reaches the row`() {
|
||||
// 理论上不会发生(有未读就一定有消息),但服务端字段是独立的:不能因为没预览就丢掉计数,
|
||||
// 否则渲染层拿不到它的唯一依据。
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
createdAt = 1L,
|
||||
unreadCount = 3,
|
||||
)
|
||||
|
||||
assertEquals(3, dto.toItem().unreadCount)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user