fix: show conversation context status

This commit is contained in:
kris
2026-04-03 10:09:19 +08:00
parent 459b301939
commit da55071a99
6 changed files with 382 additions and 33 deletions

View File

@@ -675,26 +675,41 @@ public final class BossUi {
trailingColumn.addView(unreadView);
}
LinearLayout activityWrap = new LinearLayout(context);
activityWrap.setOrientation(LinearLayout.HORIZONTAL);
activityWrap.setGravity(Gravity.END | Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams activityParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
activityParams.topMargin = dp(context, 10);
activityWrap.setLayoutParams(activityParams);
int activityCount = Math.max(0, Math.min(row.activityIconCount, WechatSurfaceMapper.maxConversationActivityIcons()));
for (int i = 0; i < activityCount; i++) {
View dot = buildAnimatedActivityDot(context, i);
if (i > 0) {
LinearLayout.LayoutParams dotParams = (LinearLayout.LayoutParams) dot.getLayoutParams();
dotParams.leftMargin = dp(context, 4);
dot.setLayoutParams(dotParams);
if (activityCount > 0) {
LinearLayout activityWrap = new LinearLayout(context);
activityWrap.setOrientation(LinearLayout.HORIZONTAL);
activityWrap.setGravity(Gravity.END | Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams activityParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
activityParams.topMargin = dp(context, 8);
activityWrap.setLayoutParams(activityParams);
for (int i = 0; i < activityCount; i++) {
View dot = buildAnimatedActivityDot(context, i);
if (i > 0) {
LinearLayout.LayoutParams dotParams = (LinearLayout.LayoutParams) dot.getLayoutParams();
dotParams.leftMargin = dp(context, 4);
dot.setLayoutParams(dotParams);
}
activityWrap.addView(dot);
}
activityWrap.addView(dot);
trailingColumn.addView(activityWrap);
}
if (!TextUtils.isEmpty(row.contextStatusLabel)) {
TextView contextView = new TextView(context);
contextView.setText(row.contextStatusLabel);
contextView.setTextSize(11);
contextView.setTypeface(Typeface.DEFAULT_BOLD);
contextView.setTextColor(resolveConversationContextColor(context, row.contextStatusLevel));
contextView.setPadding(0, dp(context, 8), 0, 0);
contextView.setMaxLines(1);
contextView.setEllipsize(TextUtils.TruncateAt.END);
contextView.setGravity(Gravity.END);
trailingColumn.addView(contextView);
}
trailingColumn.addView(activityWrap);
card.addView(trailingColumn);
return card;
@@ -1331,6 +1346,22 @@ public final class BossUi {
}
}
private static int resolveConversationContextColor(Context context, @Nullable String level) {
if (TextUtils.isEmpty(level)) {
return context.getColor(R.color.boss_text_soft);
}
switch (level) {
case "critical":
return Color.parseColor("#D94B4B");
case "urgent":
return Color.parseColor("#E0832A");
case "watch":
return Color.parseColor("#7B8A82");
default:
return context.getColor(R.color.boss_text_soft);
}
}
private static String firstLetter(String value) {
String text = value == null ? "" : value.trim();
if (text.isEmpty()) {

View File

@@ -65,7 +65,9 @@ public final class WechatSurfaceMapper {
isGroup,
isGroup ? "" : avatar == null ? "" : avatar.optString("primary", ""),
isGroup ? "" : avatar == null ? "" : avatar.optString("secondary", ""),
groupAvatarMembers.toArray(new GroupAvatarMember[0])
groupAvatarMembers.toArray(new GroupAvatarMember[0]),
buildContextStatusLabel(source),
resolveContextStatusLevel(source)
);
}
@@ -163,6 +165,40 @@ public final class WechatSurfaceMapper {
return "cancel_on_detach";
}
private static String buildContextStatusLabel(JSONObject source) {
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
return "必须收尾";
}
JSONObject indicator = source.optJSONObject("contextBudgetIndicator");
if (indicator == null || !indicator.optBoolean("visible", false)) {
return null;
}
String level = indicator.optString("level", "safe");
int percent = indicator.optInt("percent", -1);
switch (level) {
case "critical":
return percent >= 0 ? "上下文危急 " + percent + "%" : "上下文危急";
case "urgent":
return percent >= 0 ? "上下文紧张 " + percent + "%" : "上下文紧张";
case "watch":
return percent >= 0 ? "上下文关注 " + percent + "%" : "上下文关注";
default:
return percent >= 0 ? "上下文 " + percent + "%" : "上下文稳定";
}
}
private static String resolveContextStatusLevel(JSONObject source) {
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
JSONObject indicator = source.optJSONObject("contextBudgetIndicator");
return indicator == null ? "critical" : indicator.optString("level", "critical");
}
JSONObject indicator = source.optJSONObject("contextBudgetIndicator");
if (indicator == null || !indicator.optBoolean("visible", false)) {
return null;
}
return indicator.optString("level", null);
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
if ("devices".equals(activeTab)) {
return new RootTopAction("+添加", true, false, "add_device");
@@ -344,6 +380,8 @@ public final class WechatSurfaceMapper {
public final String avatarPrimary;
public final String avatarSecondary;
public final GroupAvatarMember[] groupAvatarMembers;
public final String contextStatusLabel;
public final String contextStatusLevel;
public ConversationRow(
String threadTitle,
@@ -357,6 +395,38 @@ public final class WechatSurfaceMapper {
String avatarPrimary,
String avatarSecondary,
GroupAvatarMember[] groupAvatarMembers
) {
this(
threadTitle,
folderLabel,
lastMessagePreview,
timeLabel,
unreadCount,
topPinnedLabel,
activityIconCount,
isGroup,
avatarPrimary,
avatarSecondary,
groupAvatarMembers,
null,
null
);
}
public ConversationRow(
String threadTitle,
String folderLabel,
String lastMessagePreview,
String timeLabel,
int unreadCount,
String topPinnedLabel,
int activityIconCount,
boolean isGroup,
String avatarPrimary,
String avatarSecondary,
GroupAvatarMember[] groupAvatarMembers,
String contextStatusLabel,
String contextStatusLevel
) {
this.threadTitle = threadTitle;
this.folderLabel = folderLabel;
@@ -369,6 +439,8 @@ public final class WechatSurfaceMapper {
this.avatarPrimary = avatarPrimary;
this.avatarSecondary = avatarSecondary;
this.groupAvatarMembers = groupAvatarMembers == null ? new GroupAvatarMember[0] : groupAvatarMembers;
this.contextStatusLabel = contextStatusLabel;
this.contextStatusLevel = contextStatusLevel;
}
}

View File

@@ -64,4 +64,49 @@ public class BossUiConversationRowTest {
assertTrue("预览需要保留可见宽度: " + metrics, previewView.getMeasuredWidth() > 0);
assertTrue("右侧信息列不应吞掉中间内容: " + metrics, trailingColumn.getMeasuredWidth() < rowView.getMeasuredWidth() / 2);
}
@Test
public void buildConversationRow_showsContextStatusWithoutIdleActivityDots() {
Context context = RuntimeEnvironment.getApplication();
WechatSurfaceMapper.ConversationRow row = new WechatSurfaceMapper.ConversationRow(
"北区试产线回归",
"归档确认",
"线程链路已稳定",
"09:26",
0,
"",
0,
false,
"M",
"W",
new WechatSurfaceMapper.GroupAvatarMember[0],
"上下文紧张 34%",
"urgent"
);
LinearLayout rowView = BossUi.buildConversationRow(context, row, null);
LinearLayout trailingColumn = (LinearLayout) rowView.getChildAt(2);
assertTrue(viewTreeContainsText(trailingColumn, "上下文紧张 34%"));
assertEquals("空闲会话不应再渲染活动点", 2, trailingColumn.getChildCount());
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,118 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
public class WechatSurfaceMapperConversationStatusTest {
@Test
public void toConversationRow_mapsContextStatusAndIdleActivity() {
JSONObject item = new StubJSONObject()
.withString("threadTitle", "北区试产线回归")
.withString("folderLabel", "归档确认")
.withString("lastMessagePreview", "线程链路已稳定")
.withString("latestReplyLabel", "09:26")
.withInt("activityIconCount", 0)
.withBoolean("mustFinishBeforeCompaction", false)
.withObject("contextBudgetIndicator", new StubJSONObject()
.withBoolean("visible", true)
.withInt("percent", 34)
.withString("level", "urgent"));
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals(0, row.activityIconCount);
assertEquals("上下文紧张 34%", row.contextStatusLabel);
assertEquals("urgent", row.contextStatusLevel);
}
@Test
public void toConversationRow_prefersMustFinishContextStatus() {
JSONObject item = new StubJSONObject()
.withString("threadTitle", "北区试产线回归")
.withInt("activityIconCount", 0)
.withBoolean("mustFinishBeforeCompaction", true)
.withObject("contextBudgetIndicator", new StubJSONObject()
.withBoolean("visible", true)
.withInt("percent", 18)
.withString("level", "critical"));
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("必须收尾", row.contextStatusLabel);
assertEquals("critical", row.contextStatusLevel);
}
@Test
public void toConversationRow_hidesContextStatusWhenNotVisible() {
JSONObject item = new StubJSONObject()
.withString("threadTitle", "北区试产线回归")
.withInt("activityIconCount", 0)
.withObject("contextBudgetIndicator", new StubJSONObject()
.withBoolean("visible", false)
.withInt("percent", 71)
.withString("level", "safe"));
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertNull(row.contextStatusLabel);
assertNull(row.contextStatusLevel);
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
StubJSONObject withInt(String key, int value) {
values.put(key, value);
return this;
}
StubJSONObject withBoolean(String key, boolean value) {
values.put(key, value);
return this;
}
StubJSONObject withObject(String key, JSONObject value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key, String defaultValue) {
Object value = values.get(key);
return value instanceof String ? (String) value : defaultValue;
}
@Override
public int optInt(String key, int defaultValue) {
Object value = values.get(key);
return value instanceof Integer ? (Integer) value : defaultValue;
}
@Override
public boolean optBoolean(String key, boolean defaultValue) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : defaultValue;
}
@Override
public JSONObject optJSONObject(String key) {
Object value = values.get(key);
return value instanceof JSONObject ? (JSONObject) value : null;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
}
}