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:
AskaEth committed 2026-10-04 17:42:31 +08:00
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)
}
/**
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
*