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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user