Skip to content

Commit b45d858

Browse files
SoxiaLiSAclaude
andcommitted
feat(banner): 聊天 banner 卡片重做——头像位 + 来源小标题 + 加粗昵称 + 两行正文 + 箭头
之前只有「昵称 / 一行正文」两行灰字,信息量和辨识度都不够。 模块层(通用,对 pixiv 仍一无所知): - BannerRequest.Text 新增 caption(主题色小标题,「这条从哪来」的位置) - 新增 BannerIconLoader:宿主注入图片加载,模块不引 Glide;没注入时 URL 图标直接不显示 - DefaultBannerViewBinder / banner_default.xml:44dp 圆形头像位(CardView outline 裁切,不引 CircleImageView)、 caption 12sp 主题色、标题 15sp 加粗单行、正文 14sp 两行截断、带 deepLink 且无 action 时右侧显示箭头 宿主层(ChatBannerBridge / InAppBanners): - caption:私信 / 公屏闲聊,引用了自己的消息时追加「回复了你」(7 个语言文案补齐) - 私信显示对方 pixiv 头像:按 uid 拉一次 user/detail,LruCache 64,最多等 1.5s 超时即退占位; 公屏是匿名房,故意不拉真实头像(会去匿名化),只显示 Shaft logo;拉失败同样 Shaft logo - illust-only 消息文案兜底「分享了一件作品」(服务端目前 text 必填,纯兜底) - ⚠️ gateway.incoming 是 onBufferOverflow=SUSPEND 的 SharedFlow,collector 里做网络会挂住 WS 读循环 殃及所有订阅方,bridge 前插 buffer(64, DROP_OLDEST) 隔离;取消异常不再被当成拉失败吞掉; 拉头像期间用户已进该房间则不再弹 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PpMNz7Xnv4trztaPfuSBu
1 parent 679059f commit b45d858

15 files changed

Lines changed: 262 additions & 34 deletions

File tree

app/src/main/java/ceui/pixiv/banner/host/ChatBannerBridge.kt

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package 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
37
import ceui.pixiv.banner.BannerCategory
48
import ceui.pixiv.banner.BannerDisplayPolicy
9+
import ceui.pixiv.banner.BannerIcon
510
import ceui.pixiv.banner.BannerManager
611
import ceui.pixiv.banner.BannerPriority
712
import ceui.pixiv.banner.BannerRequest
@@ -11,12 +16,16 @@ import ceui.pixiv.chat.api.ChatThreadId
1116
import ceui.pixiv.chat.api.ShaftChatGateway
1217
import ceui.pixiv.session.SessionManager
1318
import ceui.pixiv.websocket.IncomingMessage
19+
import kotlinx.coroutines.CancellationException
1420
import kotlinx.coroutines.CoroutineScope
1521
import kotlinx.coroutines.Job
22+
import kotlinx.coroutines.channels.BufferOverflow
23+
import kotlinx.coroutines.flow.buffer
1624
import kotlinx.coroutines.flow.filterIsInstance
1725
import kotlinx.coroutines.flow.map
1826
import kotlinx.coroutines.flow.mapNotNull
1927
import kotlinx.coroutines.launch
28+
import kotlinx.coroutines.withTimeoutOrNull
2029
import timber.log.Timber
2130
import 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
*/
3754
class 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
}

app/src/main/java/ceui/pixiv/banner/host/InAppBanners.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ import android.content.Context
66
import android.content.Intent
77
import android.net.Uri
88
import android.os.Bundle
9+
import android.widget.ImageView
10+
import ceui.lisa.R
911
import ceui.lisa.activities.TemplateActivity
12+
import ceui.lisa.utils.GlideUrlChild
1013
import ceui.pixiv.banner.BannerEvent
1114
import ceui.pixiv.banner.BannerHostInstaller
15+
import ceui.pixiv.banner.BannerIconLoader
1216
import ceui.pixiv.banner.BannerManager
1317
import ceui.pixiv.banner.BannerViewBinder
1418
import ceui.pixiv.banner.DefaultBannerViewBinder
1519
import ceui.pixiv.banner.RealBannerManager
20+
import com.bumptech.glide.Glide
1621
import kotlinx.coroutines.CoroutineScope
1722
import kotlinx.coroutines.Dispatchers
1823
import kotlinx.coroutines.SupervisorJob
@@ -66,7 +71,7 @@ object InAppBanners {
6671
if (!bootstrapped.compareAndSet(false, true)) return
6772

6873
val binders = mapOf<String, BannerViewBinder>(
69-
BannerViewBinder.DEFAULT_KEY to DefaultBannerViewBinder(),
74+
BannerViewBinder.DEFAULT_KEY to DefaultBannerViewBinder(iconLoader = GlideBannerIconLoader),
7075
)
7176
manager = RealBannerManager(binders = binders)
7277
manager.start()
@@ -80,7 +85,7 @@ object InAppBanners {
8085
installer.installNow(it)
8186
}
8287

83-
ChatBannerBridge(manager, scope).start()
88+
ChatBannerBridge(app, manager, scope).start()
8489

8590
scope.launch {
8691
manager.events
@@ -122,6 +127,24 @@ object InAppBanners {
122127
}
123128
}
124129

130+
/**
131+
* 宿主侧的 [BannerIcon.Url] 加载器:走 Glide + [GlideUrlChild](pximg 需要 referer),
132+
* 圆形裁切,占位用 Shaft 自己的 logo(与 [ChatBannerBridge] 的匿名 / 失败兜底一致;
133+
* 不用 chat_avatar_placeholder —— 它依赖 Material 的 colorSurfaceContainerHigh,
134+
* AppCompat 主题的 MainActivity 上解析不了)。
135+
*/
136+
private object GlideBannerIconLoader : BannerIconLoader {
137+
override fun load(target: ImageView, url: String) {
138+
runCatching {
139+
Glide.with(target)
140+
.load(GlideUrlChild(url))
141+
.placeholder(R.drawable.icon_shaft_with_bg)
142+
.circleCrop()
143+
.into(target)
144+
}.onFailure { Timber.tag(TAG).w(it, "banner icon load failed") }
145+
}
146+
}
147+
125148
/**
126149
* Cheapest possible foreground-activity tracker. WeakReference so we never
127150
* keep an Activity alive past `onDestroy`, and we only care about which

app/src/main/res/values-en/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2273,6 +2273,10 @@
22732273
<string name="network_test_overall_clean_sub_fallback">DNS resolution, handshake, size probe, image downloads OK.</string>
22742274
<string name="chat_drawer_entry">Chat Room</string>
22752275
<string name="chat_room_global_title">Public Lounge</string>
2276+
<!-- 应用内 banner(ceui.pixiv.banner.host.ChatBannerBridge)的小标题 / 正文兜底 -->
2277+
<string name="chat_banner_caption_dm">Direct message</string>
2278+
<string name="chat_banner_caption_reply_to_you">replied to you</string>
2279+
<string name="chat_banner_shared_illust">Shared an artwork</string>
22762280
<string name="chat_preview_you_prefix">You:</string>
22772281
<string name="chat_room_count">%1$d conversations</string>
22782282
<string name="chat_new_messages">%1$d new messages</string>

app/src/main/res/values-ja/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,10 @@
22562256
<string name="network_test_overall_clean_sub_fallback">DNS 解決、ハンドシェイク、サイズ検証、画像ダウンロードも正常です。</string>
22572257
<string name="chat_drawer_entry">チャットルーム</string>
22582258
<string name="chat_room_global_title">公開ラウンジ</string>
2259+
<!-- 应用内 banner(ceui.pixiv.banner.host.ChatBannerBridge)的小标题 / 正文兜底 -->
2260+
<string name="chat_banner_caption_dm">ダイレクトメッセージ</string>
2261+
<string name="chat_banner_caption_reply_to_you">あなたに返信</string>
2262+
<string name="chat_banner_shared_illust">作品をシェアしました</string>
22592263
<string name="chat_preview_you_prefix">自分:</string>
22602264
<string name="chat_room_count">%1$d 件のトーク</string>
22612265
<string name="chat_new_messages">新着 %1$d 件</string>

app/src/main/res/values-ko/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,10 @@
22562256
<string name="network_test_overall_clean_sub_fallback">DNS 해석, 핸드셰이크, 크기 검사, 이미지 다운로드도 정상입니다.</string>
22572257
<string name="chat_drawer_entry">채팅방</string>
22582258
<string name="chat_room_global_title">공개 라운지</string>
2259+
<!-- 应用内 banner(ceui.pixiv.banner.host.ChatBannerBridge)的小标题 / 正文兜底 -->
2260+
<string name="chat_banner_caption_dm">다이렉트 메시지</string>
2261+
<string name="chat_banner_caption_reply_to_you">나에게 답장</string>
2262+
<string name="chat_banner_shared_illust">작품을 공유했습니다</string>
22592263
<string name="chat_preview_you_prefix">나:</string>
22602264
<string name="chat_room_count">대화 %1$d개</string>
22612265
<string name="chat_new_messages">새 메시지 %1$d개</string>

app/src/main/res/values-ru/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,10 @@
22562256
<string name="network_test_overall_clean_sub_fallback">Разрешение DNS, рукопожатие, определение размера, загрузка изображений в порядке.</string>
22572257
<string name="chat_drawer_entry">Чат</string>
22582258
<string name="chat_room_global_title">Общий чат</string>
2259+
<!-- 应用内 banner(ceui.pixiv.banner.host.ChatBannerBridge)的小标题 / 正文兜底 -->
2260+
<string name="chat_banner_caption_dm">Личное сообщение</string>
2261+
<string name="chat_banner_caption_reply_to_you">ответил(а) вам</string>
2262+
<string name="chat_banner_shared_illust">Поделился(-ась) работой</string>
22592263
<string name="chat_preview_you_prefix">Вы:</string>
22602264
<string name="chat_room_count">Диалогов: %1$d</string>
22612265
<string name="chat_new_messages">Новых: %1$d</string>

app/src/main/res/values-tr/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,10 @@
22562256
<string name="network_test_overall_clean_sub_fallback">DNS çözümlemesi, el sıkışma, boyut testi, görsel indirmeleri sorunsuz.</string>
22572257
<string name="chat_drawer_entry">Sohbet Odası</string>
22582258
<string name="chat_room_global_title">Genel Sohbet</string>
2259+
<!-- 应用内 banner(ceui.pixiv.banner.host.ChatBannerBridge)的小标题 / 正文兜底 -->
2260+
<string name="chat_banner_caption_dm">Özel mesaj</string>
2261+
<string name="chat_banner_caption_reply_to_you">size yanıt verdi</string>
2262+
<string name="chat_banner_shared_illust">Bir eser paylaştı</string>
22592263
<string name="chat_preview_you_prefix">Siz:</string>
22602264
<string name="chat_room_count">%1$d sohbet</string>
22612265
<string name="chat_new_messages">%1$d yeni mesaj</string>

app/src/main/res/values-zh-rTW/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2272,6 +2272,10 @@
22722272
<string name="network_test_overall_clean_sub_fallback">DNS 解析、握手、尺寸探測、圖片下載一切正常。</string>
22732273
<string name="chat_drawer_entry">聊天室</string>
22742274
<string name="chat_room_global_title">公屏閒聊</string>
2275+
<!-- 应用内 banner(ceui.pixiv.banner.host.ChatBannerBridge)的小标题 / 正文兜底 -->
2276+
<string name="chat_banner_caption_dm">私訊</string>
2277+
<string name="chat_banner_caption_reply_to_you">回覆了你</string>
2278+
<string name="chat_banner_shared_illust">分享了一件作品</string>
22752279
<string name="chat_preview_you_prefix">你:</string>
22762280
<string name="chat_room_count">%1$d 個對話</string>
22772281
<string name="chat_new_messages">%1$d 則新訊息</string>

app/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2523,6 +2523,10 @@
25232523
<!-- Chat subsystem (borrowed from Peanut; see ceui.pixiv.chat.*) -->
25242524
<string name="chat_drawer_entry">聊天室</string>
25252525
<string name="chat_room_global_title">公屏闲聊</string>
2526+
<!-- 应用内 banner(ceui.pixiv.banner.host.ChatBannerBridge)的小标题 / 正文兜底 -->
2527+
<string name="chat_banner_caption_dm">私信</string>
2528+
<string name="chat_banner_caption_reply_to_you">回复了你</string>
2529+
<string name="chat_banner_shared_illust">分享了一件作品</string>
25262530
<string name="chat_preview_you_prefix">你:</string>
25272531
<string name="chat_room_count">%1$d 个对话</string>
25282532
<string name="chat_new_messages">%1$d 条新消息</string>

banner/src/main/java/ceui/pixiv/banner/BannerIcon.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ sealed interface BannerIcon {
55
/** Resource id from the host module's `R.drawable`. */
66
data class Resource(val resId: Int) : BannerIcon
77

8-
/** Remote image to be fetched and decoded by the image pipeline. */
8+
/**
9+
* Remote image. The banner module has no image pipeline of its own — the
10+
* host supplies a [BannerIconLoader] to [DefaultBannerViewBinder]; without
11+
* one, URL icons are simply not shown.
12+
*/
913
data class Url(val url: String) : BannerIcon
1014
}

0 commit comments

Comments
 (0)