11package ceui.pixiv.banner.host
22
3+ import android.content.Context
4+ import android.util.LruCache
5+ import ceui.lisa.R
6+ import ceui.loxia.Client
37import ceui.pixiv.banner.BannerCategory
48import ceui.pixiv.banner.BannerDisplayPolicy
9+ import ceui.pixiv.banner.BannerIcon
510import ceui.pixiv.banner.BannerManager
611import ceui.pixiv.banner.BannerPriority
712import ceui.pixiv.banner.BannerRequest
@@ -11,12 +16,16 @@ import ceui.pixiv.chat.api.ChatThreadId
1116import ceui.pixiv.chat.api.ShaftChatGateway
1217import ceui.pixiv.session.SessionManager
1318import ceui.pixiv.websocket.IncomingMessage
19+ import kotlinx.coroutines.CancellationException
1420import kotlinx.coroutines.CoroutineScope
1521import kotlinx.coroutines.Job
22+ import kotlinx.coroutines.channels.BufferOverflow
23+ import kotlinx.coroutines.flow.buffer
1624import kotlinx.coroutines.flow.filterIsInstance
1725import kotlinx.coroutines.flow.map
1826import kotlinx.coroutines.flow.mapNotNull
1927import kotlinx.coroutines.launch
28+ import kotlinx.coroutines.withTimeoutOrNull
2029import timber.log.Timber
2130import java.util.UUID
2231
@@ -33,12 +42,24 @@ import java.util.UUID
3342 *
3443 * Newer messages in the same room use `Replace` (dedupKey="chat-<room>") so
3544 * they supersede the previous banner instead of stacking.
45+ *
46+ * What the card shows (see [toBannerRequest]):
47+ * - caption: `私信` / `公屏闲聊`, plus `回复了你` when the message quotes one of ours
48+ * - title: sender display name
49+ * - message: the text, or "分享了一件作品" for illust-only messages
50+ * - icon: the peer's pixiv avatar for 1v1 (resolved once per uid, bounded by
51+ * [AVATAR_FETCH_TIMEOUT_MS]); the global room is anonymous so it only ever
52+ * gets the Shaft logo — never fetch a real profile for it.
3653 */
3754class ChatBannerBridge (
55+ private val context : Context ,
3856 private val bannerManager : BannerManager ,
3957 private val scope : CoroutineScope ,
4058) {
4159
60+ /* * uid → avatar url. Only successful lookups are cached; failures retry on the next message. */
61+ private val avatarCache = LruCache <Long , String >(64 )
62+
4263 private var job: Job ? = null
4364
4465 fun start () {
@@ -48,6 +69,11 @@ class ChatBannerBridge(
4869 .filterIsInstance<IncomingMessage .Text >()
4970 .map { ChatFrameDecoder .decode(it.text) }
5071 .filterIsInstance<ChatFrame .Msg >()
72+ // ⚠️ 下游 toBannerRequest 会做网络(拉头像,最多 AVATAR_FETCH_TIMEOUT_MS)。
73+ // gateway.incoming 底层是 onBufferOverflow=SUSPEND 的 SharedFlow,任何一个慢订阅者
74+ // 把缓冲填满都会挂住 WS 读循环、殃及聊天 UI / 持久化等所有订阅方。这里切出独立缓冲,
75+ // 突发时宁可丢掉最旧的几条 banner,也绝不让上游等我们。
76+ .buffer(BANNER_BUFFER , BufferOverflow .DROP_OLDEST )
5177 .mapNotNull { toBannerRequest(it) }
5278 .collect { bannerManager.enqueue(it) }
5379 }
@@ -59,24 +85,40 @@ class ChatBannerBridge(
5985 job = null
6086 }
6187
62- private fun toBannerRequest (msg : ChatFrame .Msg ): BannerRequest .Text ? {
88+ private suspend fun toBannerRequest (msg : ChatFrame .Msg ): BannerRequest .Text ? {
6389 val selfUid = SessionManager .loggedInUid
6490 if (selfUid != 0L && msg.uid == selfUid) return null
91+ val isGlobal = msg.room == ChatThreadId .ROOM_GLOBAL
6592 // 试验性开关只 gate 公开/全局房 banner(默认关)。1v1 私信 banner **故意不 gate**:
6693 // 私信入口是用户主页的「发消息」按钮(UserActivityV3,独立于侧边栏「聊天室入口」开关),
6794 // 是用户主动发起的会话;若按聊天室入口开关把回复通知静默掉,反而是更严重的 bug。
6895 // ⚠️ 别为了「一致性」把这条收紧成 !showChatRoomEntry 就 return —— 会吞掉私信通知。
69- if (msg.room == ChatThreadId . ROOM_GLOBAL && ! publicChatBannerEnabled()) {
96+ if (isGlobal && ! publicChatBannerEnabled()) {
7097 return null
7198 }
7299 if (isViewingRoom(msg.room)) {
73100 Timber .tag(TAG ).d(" suppress banner: foreground is room=%s" , msg.room)
74101 return null
75102 }
76103 val body = msg.text?.takeIf { it.isNotBlank() }
77- ? : msg.illustId?.let { " [illust: $it ] " }
104+ ? : msg.illustId?.let { context.getString( R .string.chat_banner_shared_illust) }
78105 ? : return null
79106 val sender = msg.displayName?.takeIf { it.isNotBlank() } ? : " uid ${msg.uid} "
107+ val caption = buildList {
108+ add(context.getString(if (isGlobal) R .string.chat_room_global_title else R .string.chat_banner_caption_dm))
109+ if (selfUid != 0L && msg.replyTo?.uid == selfUid) add(context.getString(R .string.chat_banner_caption_reply_to_you))
110+ }.joinToString(" · " )
111+ // 公屏是匿名房(display_name 是「匿名_xxx」),拉真实头像等于把人去匿名化 —— 只给 Shaft logo。
112+ val icon: BannerIcon = if (isGlobal) {
113+ PLACEHOLDER_ICON
114+ } else {
115+ avatarFor(msg.uid)?.let { BannerIcon .Url (it) } ? : PLACEHOLDER_ICON
116+ }
117+ // 拉头像期间用户可能已经点进了这个房间(首屏正好在会话列表时最常见),再确认一次。
118+ if (isViewingRoom(msg.room)) {
119+ Timber .tag(TAG ).d(" suppress banner after avatar fetch: foreground is room=%s" , msg.room)
120+ return null
121+ }
80122 // 1v1 room id is a hashed pair → cannot reverse to peer uid. But the
81123 // sender (msg.uid, already filtered against self) IS the peer for 1v1,
82124 // so encode that directly. Global rooms drop the peer param.
@@ -89,6 +131,8 @@ class ChatBannerBridge(
89131 id = UUID .randomUUID().toString(),
90132 title = sender,
91133 message = body,
134+ caption = caption,
135+ icon = icon,
92136 dedupKey = " chat-${msg.room} " ,
93137 priority = BannerPriority .NORMAL ,
94138 category = BannerCategory .Chat ,
@@ -102,6 +146,26 @@ class ChatBannerBridge(
102146 )
103147 }
104148
149+ /* *
150+ * 私信对方的 pixiv 头像。首次按 uid 拉一次 `user/detail`,之后命中缓存零延迟;
151+ * 网络慢时最多等 [AVATAR_FETCH_TIMEOUT_MS],超时 / 失败就退回占位图,绝不让 banner 干等。
152+ */
153+ private suspend fun avatarFor (uid : Long ): String? {
154+ avatarCache.get(uid)?.let { return it }
155+ val url = withTimeoutOrNull(AVATAR_FETCH_TIMEOUT_MS ) {
156+ try {
157+ Client .appApi.getUserProfile(uid).user?.profile_image_urls?.findMaxSizeUrl()
158+ } catch (e: CancellationException ) {
159+ throw e // 超时 / bridge.stop() 的取消必须冒泡,不能当成「拉失败」吞掉
160+ } catch (e: Exception ) {
161+ Timber .tag(TAG ).v(e, " avatar fetch failed for uid=%d" , uid)
162+ null
163+ }
164+ }
165+ if (! url.isNullOrBlank()) avatarCache.put(uid, url)
166+ return url
167+ }
168+
105169 /* *
106170 * Is the user already looking at the chat room that produced [msgRoom]?
107171 *
@@ -130,5 +194,9 @@ class ChatBannerBridge(
130194
131195 companion object {
132196 private const val TAG = " Chat-Banner-Bridge"
197+ private const val AVATAR_FETCH_TIMEOUT_MS = 1500L
198+ private const val BANNER_BUFFER = 64
199+ /* * 匿名发言 / 头像拉不到时的头像位:Shaft 自己的 logo,而不是任何 pixiv 品牌图。 */
200+ private val PLACEHOLDER_ICON = BannerIcon .Resource (R .drawable.icon_shaft_with_bg)
133201 }
134202}
0 commit comments