diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index decbeb6..b14b8f5 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -681,6 +681,17 @@ public final class BossUi { boolean selectionMode, boolean selected, @Nullable View.OnClickListener listener + ) { + return buildConversationRow(context, row, selectionMode, selected, false, listener); + } + + public static LinearLayout buildConversationRow( + Context context, + WechatSurfaceMapper.ConversationRow row, + boolean selectionMode, + boolean selected, + boolean highlighted, + @Nullable View.OnClickListener listener ) { LinearLayout card = new LinearLayout(context); card.setOrientation(LinearLayout.HORIZONTAL); @@ -692,7 +703,7 @@ public final class BossUi { params.bottomMargin = dp(context, 1); card.setLayoutParams(params); card.setPadding(dp(context, 16), dp(context, 12), dp(context, 16), dp(context, 12)); - card.setBackgroundColor(row.pinnedConversation ? PINNED_ROW_BG : Color.WHITE); + card.setBackgroundColor(highlighted ? Color.parseColor("#EAF8F0") : row.pinnedConversation ? PINNED_ROW_BG : Color.WHITE); card.setElevation(0f); if (listener != null) { card.setClickable(true); @@ -789,6 +800,23 @@ public final class BossUi { trailingColumn.addView(selector); } + if (highlighted) { + TextView targetView = new TextView(context); + targetView.setText("目标线程"); + targetView.setTextSize(11); + targetView.setTypeface(Typeface.DEFAULT_BOLD); + targetView.setTextColor(context.getColor(R.color.boss_green)); + targetView.setBackground(createRoundedBackground(Color.parseColor("#E7F7EE"), dp(context, 9))); + targetView.setPadding(dp(context, 7), dp(context, 3), dp(context, 7), dp(context, 3)); + LinearLayout.LayoutParams targetParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + targetParams.topMargin = dp(context, 8); + targetView.setLayoutParams(targetParams); + trailingColumn.addView(targetView); + } + if (row.unreadCount > 0) { TextView unreadView = new TextView(context); unreadView.setText(row.unreadCount > 99 ? "99+" : String.valueOf(row.unreadCount)); diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java index 2702a9f..f976533 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java @@ -8,19 +8,41 @@ import androidx.annotation.Nullable; import org.json.JSONArray; import org.json.JSONObject; +import java.util.ArrayList; + public class ConversationFolderActivity extends BossScreenActivity { public static final String EXTRA_FOLDER_KEY = "folder_key"; public static final String EXTRA_FOLDER_NAME = "folder_name"; + public static final String EXTRA_TARGET_PROJECT_ID = "target_project_id"; + public static final String EXTRA_TARGET_PROJECT_IDS = "target_project_ids"; + public static final String EXTRA_TARGET_PROJECT_LABEL = "target_project_label"; private String folderKey; private String folderName; + private String targetProjectId; + private ArrayList targetProjectIds; + private String targetProjectLabel; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); folderKey = getIntent().getStringExtra(EXTRA_FOLDER_KEY); folderName = getIntent().getStringExtra(EXTRA_FOLDER_NAME); - configureScreen(folderName == null || folderName.isEmpty() ? "项目线程" : folderName, "同一项目下的线程列表"); + targetProjectId = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_ID); + targetProjectIds = new ArrayList<>(); + String[] extraTargetProjectIds = getIntent().getStringArrayExtra(EXTRA_TARGET_PROJECT_IDS); + if (extraTargetProjectIds != null) { + for (String item : extraTargetProjectIds) { + if (item != null) { + String trimmed = item.trim(); + if (!trimmed.isEmpty()) { + targetProjectIds.add(trimmed); + } + } + } + } + targetProjectLabel = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_LABEL); + configureScreen(folderName == null || folderName.isEmpty() ? "项目线程" : folderName, "0 个线程"); refreshButton.setVisibility(android.view.View.GONE); setHeaderAction("...", v -> showMoreMenu()); reload(); @@ -59,13 +81,12 @@ public class ConversationFolderActivity extends BossScreenActivity { } String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName); - String deviceName = folder.optString("deviceName", ""); int threadCount = folder.optInt("threadCount", 0); - configureScreen(resolvedFolderName, deviceName.isEmpty() ? "项目线程" : deviceName); + configureScreen(resolvedFolderName, threadCount + " 个线程"); appendContent(BossUi.buildSoftPanel( this, + "项目内部线程页", resolvedFolderName, - (deviceName.isEmpty() ? "当前设备" : deviceName) + "\n共 " + threadCount + " 个线程", "点击线程后进入具体聊天窗口。" )); @@ -75,26 +96,97 @@ public class ConversationFolderActivity extends BossScreenActivity { setRefreshing(false); return; } - for (int i = 0; i < threads.length(); i++) { - JSONObject item = threads.optJSONObject(i); - if (item == null) continue; - String projectId = item.optString("projectId", ""); - WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); - appendContent(BossUi.buildConversationRow( + + ArrayList targetIndices = resolveTargetThreadIndices(threads); + if (!targetIndices.isEmpty()) { + String matchedLabel = targetProjectLabel; + if ((matchedLabel == null || matchedLabel.isEmpty())) { + JSONObject firstTarget = threads.optJSONObject(targetIndices.get(0)); + if (firstTarget != null) { + matchedLabel = firstTarget.optString("threadTitle", ""); + } + } + appendContent(BossUi.buildSoftPanel( this, - row, - v -> { - if (projectId.isEmpty()) { - showMessage("缺少 projectId"); - return; - } - openProject(projectId, row.threadTitle); - } + "已定位到目标线程", + matchedLabel == null || matchedLabel.isEmpty() + ? "文件夹页已打开,并将匹配线程置顶显示。" + : matchedLabel, + targetIndices.size() + " 个匹配项已置顶" )); } + + for (int i = 0; i < targetIndices.size(); i++) { + renderThreadAtIndex(threads, targetIndices.get(i), true); + } + for (int i = 0; i < threads.length(); i++) { + if (!targetIndices.contains(i)) { + renderThreadAtIndex(threads, i, false); + } + } setRefreshing(false); } + private void renderThreadAtIndex(JSONArray threads, int index, boolean highlighted) { + JSONObject item = threads.optJSONObject(index); + if (item == null) return; + String projectId = item.optString("projectId", ""); + WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); + appendContent(BossUi.buildConversationRow( + this, + row, + false, + false, + highlighted, + v -> { + if (projectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + openProject(projectId, row.threadTitle); + } + )); + } + + private ArrayList resolveTargetThreadIndices(JSONArray threads) { + ArrayList targetIndices = new ArrayList<>(); + if (threads == null || threads.length() == 0) { + return targetIndices; + } + + if (!targetProjectIds.isEmpty()) { + for (int i = 0; i < threads.length(); i++) { + JSONObject item = threads.optJSONObject(i); + if (item == null) continue; + String projectId = item.optString("projectId", ""); + if (targetProjectIds.contains(projectId)) { + targetIndices.add(i); + } + } + return targetIndices; + } + + if (targetProjectId != null && !targetProjectId.isEmpty()) { + for (int i = 0; i < threads.length(); i++) { + JSONObject item = threads.optJSONObject(i); + if (item != null && targetProjectId.equals(item.optString("projectId", ""))) { + targetIndices.add(i); + return targetIndices; + } + } + } + + if (targetProjectLabel != null && !targetProjectLabel.isEmpty()) { + for (int i = 0; i < threads.length(); i++) { + JSONObject item = threads.optJSONObject(i); + if (item != null && targetProjectLabel.equals(item.optString("threadTitle", ""))) { + targetIndices.add(i); + } + } + } + return targetIndices; + } + private void openProject(String projectId, String projectName) { Intent intent = new Intent(this, ProjectDetailActivity.class); intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, projectId); diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index 24e289d..1c4e826 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -1091,7 +1091,13 @@ public class MainActivity extends AppCompatActivity { showMessage("缺少 folderKey"); return; } - openConversationFolder(folderKey, resolveConversationFolderName(item, row)); + openConversationFolder( + folderKey, + resolveConversationFolderName(item, row), + item.optString("searchMatchProjectId", ""), + item.optJSONArray("searchMatchProjectIds"), + item.optString("searchMatchLabel", "") + ); return; } if (projectId.isEmpty()) { @@ -1319,6 +1325,11 @@ public class MainActivity extends AppCompatActivity { String matchLabel = resolveConversationSearchMatchLabel(item, query); if (!matchLabel.isEmpty()) { filteredItem.put("searchMatchLabel", matchLabel); + JSONArray matchProjectIds = resolveConversationSearchMatchTargetIds(item, matchLabel); + if (matchProjectIds.length() > 0) { + filteredItem.put("searchMatchProjectId", matchProjectIds.optString(0, "")); + filteredItem.put("searchMatchProjectIds", matchProjectIds); + } } } catch (Exception ignored) { // Keep the original item if JSON cloning fails. @@ -1466,6 +1477,33 @@ public class MainActivity extends AppCompatActivity { return ""; } + private static JSONArray resolveConversationSearchMatchTargetIds(JSONObject item, String matchLabel) { + JSONArray matchedProjectIds = new JSONArray(); + if (item == null || matchLabel == null || matchLabel.isEmpty()) { + return matchedProjectIds; + } + JSONArray searchAliases = item.optJSONArray("searchAliases"); + JSONArray searchTargetProjectIds = item.optJSONArray("searchTargetProjectIds"); + if (searchAliases == null || searchTargetProjectIds == null) { + JSONArray legacyTargets = item.optJSONArray("searchAliasTargets"); + if (searchAliases == null || legacyTargets == null) { + return matchedProjectIds; + } + searchTargetProjectIds = legacyTargets; + } + int limit = Math.min(searchAliases.length(), searchTargetProjectIds.length()); + for (int i = 0; i < limit; i++) { + String alias = searchAliases.optString(i, "").trim(); + if (!alias.isEmpty() && alias.equals(matchLabel)) { + String projectId = searchTargetProjectIds.optString(i, "").trim(); + if (!projectId.isEmpty()) { + matchedProjectIds.put(projectId); + } + } + } + return matchedProjectIds; + } + private void renderDevicesRoot() { if (screenList == null) { return; @@ -1583,9 +1621,37 @@ public class MainActivity extends AppCompatActivity { } private void openConversationFolder(String folderKey, String folderName) { + openConversationFolder(folderKey, folderName, "", null, ""); + } + + private void openConversationFolder( + String folderKey, + String folderName, + String targetProjectId, + JSONArray targetProjectIds, + String targetProjectLabel + ) { Intent intent = new Intent(this, ConversationFolderActivity.class); intent.putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, folderKey); intent.putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, folderName); + if (targetProjectId != null && !targetProjectId.isEmpty()) { + intent.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID, targetProjectId); + } + if (targetProjectIds != null && targetProjectIds.length() > 0) { + ArrayList ids = new ArrayList<>(); + for (int i = 0; i < targetProjectIds.length(); i++) { + String id = targetProjectIds.optString(i, "").trim(); + if (!id.isEmpty()) { + ids.add(id); + } + } + if (!ids.isEmpty()) { + intent.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS, ids.toArray(new String[0])); + } + } + if (targetProjectLabel != null && !targetProjectLabel.isEmpty()) { + intent.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL, targetProjectLabel); + } startActivity(intent); } diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java index cf727f6..c7caa16 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java @@ -45,11 +45,15 @@ public class ConversationFolderActivityTest { ImageButton headerAction = activity.findViewById(R.id.screen_header_action); ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button); LinearLayout content = activity.findViewById(R.id.screen_content); + TextView titleView = activity.findViewById(R.id.screen_title); + TextView subtitleView = activity.findViewById(R.id.screen_subtitle); assertEquals("更多", String.valueOf(headerAction.getContentDescription())); assertEquals(View.GONE, refreshButton.getVisibility()); + assertEquals("Talking", String.valueOf(titleView.getText())); + assertEquals("3 个线程", String.valueOf(subtitleView.getText())); assertTrue(viewTreeContainsText(content, "Talking")); - assertTrue(viewTreeContainsText(content, "查询腾讯HAI GPU服务器")); + assertTrue(viewTreeContainsText(content, "项目内部线程页")); ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu"); @@ -59,27 +63,79 @@ public class ConversationFolderActivityTest { assertTrue(viewTreeContainsText(listView.getAdapter().getView(0, null, listView), "刷新")); } + @Test + public void conversationFolderHighlightsTargetThreadWhenSearchTargetIsProvided() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking") + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking") + .putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID, "project-1") + .putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS, new String[]{"project-1", "project-2"}) + .putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL, "发布回滚"); + TestConversationFolderActivity activity = Robolectric + .buildActivity(TestConversationFolderActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderFolder", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "已定位到目标线程")); + assertTrue(viewTreeContainsText(content, "目标线程")); + assertTrue(viewTreeContainsText(content, "发布回滚")); + assertEquals(2, countTextOccurrences(content, "目标线程")); + assertTrue(countTextOccurrences(content, "发布回滚") >= 3); + assertEquals(0, countTextOccurrences(content, "project-1")); + } + private static JSONObject buildFolderPayload() throws Exception { JSONArray threads = new JSONArray() .put(new JSONObject() .put("projectId", "project-1") - .put("threadTitle", "查询腾讯HAI GPU服务器") + .put("threadTitle", "发布回滚") .put("folderLabel", "Talking") .put("lastMessagePreview", "已从设备导入线程") .put("timeLabel", "02:28")) .put(new JSONObject() .put("projectId", "project-2") - .put("threadTitle", "状态栏显示CPU和内存") + .put("threadTitle", "发布回滚") .put("folderLabel", "Talking") .put("lastMessagePreview", "已从设备导入线程") - .put("timeLabel", "02:12")); + .put("timeLabel", "02:12")) + .put(new JSONObject() + .put("projectId", "project-3") + .put("threadTitle", "日志收口") + .put("folderLabel", "Talking") + .put("lastMessagePreview", "已从设备导入线程") + .put("timeLabel", "02:05")); return new JSONObject() .put("folderLabel", "Talking") .put("deviceName", "Mac Studio") - .put("threadCount", 2) + .put("threadCount", 3) .put("threads", threads); } + private static int countTextOccurrences(View root, String expectedText) { + int count = 0; + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (expectedText.contentEquals(text)) { + count += 1; + } + } + if (!(root instanceof ViewGroup)) { + return count; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + count += countTextOccurrences(group.getChildAt(index), expectedText); + } + return count; + } + private static boolean viewTreeContainsText(View root, String expectedText) { if (root instanceof TextView) { CharSequence text = ((TextView) root).getText(); diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java index 73cca64..97c9a3f 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java @@ -59,7 +59,8 @@ public class MainActivityConversationSearchTest { .put("projectTitle", "Boss") .put("threadTitle", "Boss") .put("folderLabel", "2 个线程 · 最近:发布回滚") - .put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾")) + .put("searchAliases", new JSONArray().put("发布回滚").put("发布回滚").put("Android UI 收尾")) + .put("searchTargetProjectIds", new JSONArray().put("thread-revert-1").put("thread-revert-2").put("thread-ui")) .put("lastMessagePreview", "最近:发布回滚") .put("latestReplyLabel", "11:00")); @@ -68,6 +69,9 @@ public class MainActivityConversationSearchTest { assertEquals(1, filtered.length()); assertEquals("folder-boss", filtered.optJSONObject(0).optString("projectId", "")); assertEquals("发布回滚", filtered.optJSONObject(0).optString("searchMatchLabel", "")); + assertEquals("thread-revert-1", filtered.optJSONObject(0).optString("searchMatchProjectId", "")); + assertEquals(2, filtered.optJSONObject(0).optJSONArray("searchMatchProjectIds").length()); + assertEquals("thread-revert-2", filtered.optJSONObject(0).optJSONArray("searchMatchProjectIds").optString(1, "")); } @Test @@ -132,7 +136,8 @@ public class MainActivityConversationSearchTest { .put("threadTitle", "Boss") .put("lastMessagePreview", "最近:发布回滚") .put("latestReplyLabel", "11:00") - .put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾")))); + .put("searchAliases", new JSONArray().put("发布回滚").put("发布回滚").put("Android UI 收尾")) + .put("searchTargetProjectIds", new JSONArray().put("thread-revert-1").put("thread-revert-2").put("thread-ui")))); ReflectionHelpers.callInstanceMethod(activity, "showContent"); Shadows.shadowOf(activity.getMainLooper()).idle(); @@ -154,6 +159,10 @@ public class MainActivityConversationSearchTest { assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName()); assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY)); assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME)); + assertEquals("thread-revert-1", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID)); + assertEquals(2, nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS).length); + assertEquals("thread-revert-2", nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS)[1]); + assertEquals("发布回滚", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL)); } private static JSONArray buildConversations() throws Exception { diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java index 184ef6e..7d4194a 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java @@ -239,6 +239,27 @@ public class MainActivityRealtimeTest { assertEquals(0, apiClient.conversationsCalls); } + @Test + public void refreshConversationsData_fallsBackToFlatConversationsFeedWhenHomeFeedFails() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient( + activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + activity.refreshConversationsData(); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + + assertEquals(1, apiClient.homeCalls); + assertEquals(1, apiClient.conversationsCalls); + JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); + assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + } + @Test public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); @@ -262,6 +283,31 @@ public class MainActivityRealtimeTest { assertEquals(0, apiClient.conversationsCalls); } + @Test + public void refreshAllData_fallsBackToFlatConversationsFeedWhenHomeFeedFails() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient( + activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + ReflectionHelpers.callInstanceMethod( + activity, + "refreshAllData", + ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()) + ); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + + assertEquals(1, apiClient.homeCalls); + assertEquals(1, apiClient.conversationsCalls); + JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); + assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + } + private static void waitFor(BooleanSupplier condition) throws Exception { long deadlineAt = System.currentTimeMillis() + 2_000L; while (System.currentTimeMillis() < deadlineAt) { @@ -298,6 +344,81 @@ public class MainActivityRealtimeTest { } } + private static final class RecordingRejectedConversationSourceClient extends BossApiClient { + int homeCalls; + int conversationsCalls; + int sessionCalls; + int devicesCalls; + int settingsCalls; + int otaCalls; + + RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) { + super(prefs, "https://boss.hyzq.net"); + } + + @Override + public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException { + homeCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", false) + .put("message", "HOME_UNAVAILABLE")); + } + + @Override + public ApiResponse getConversations() throws java.io.IOException, org.json.JSONException { + conversationsCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("conversations", buildFlatConversations())); + } + + @Override + public ApiResponse getSession() throws java.io.IOException, org.json.JSONException { + sessionCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("session", new JSONObject() + .put("account", "17600003315") + .put("displayName", "Boss 超级管理员"))); + } + + @Override + public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException { + devicesCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("devices", new JSONArray())); + } + + @Override + public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException { + settingsCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("settings", new JSONObject().put("preferredEntryPoint", "conversations")) + .put("user", new JSONObject())); + } + + @Override + public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException { + otaCalls += 1; + return new ApiResponse(200, new JSONObject() + .put("ok", true) + .put("hasOta", false)); + } + + private static JSONArray buildFlatConversations() throws org.json.JSONException { + return new JSONArray().put(new JSONObject() + .put("projectId", "flat-thread") + .put("conversationType", "single_device") + .put("projectTitle", "发布回滚") + .put("threadTitle", "发布回滚") + .put("folderLabel", "Boss") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyLabel", "11:00")); + } + } + private static final class RecordingConversationSourceClient extends BossApiClient { int homeCalls; int conversationsCalls; diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index 61dbcec..2a4e979 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -45,6 +45,7 @@ export interface ConversationItem { folderKey?: string; threadCount?: number; searchAliases?: string[]; + searchTargetProjectIds?: string[]; preview: string; lastMessagePreview: string; activityIconCount: number; @@ -470,14 +471,19 @@ function sortConversationItems(items: ConversationItem[]) { function buildFolderSearchAliases(items: ConversationItem[]) { const aliases: string[] = []; - const seen = new Set(); + const targetProjectIds: string[] = []; for (const item of items) { const alias = (item.threadTitle?.trim() || item.projectTitle?.trim() || "").trim(); - if (!alias || seen.has(alias)) continue; + if (!alias) continue; aliases.push(alias); - seen.add(alias); + targetProjectIds.push(item.projectId); } - return aliases.length > 0 ? aliases : undefined; + return aliases.length > 0 + ? { + aliases, + targetProjectIds, + } + : undefined; } export function getConversationItems(state: BossState): ConversationItem[] { @@ -541,6 +547,7 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { return b.latestReplyAt.localeCompare(a.latestReplyAt); })[0]; const recentThreadLabel = latestItem.threadTitle.trim(); + const searchAliases = buildFolderSearchAliases(items); passthrough.push({ conversationId: `folder-${folderKey}`, conversationType: "folder_archive", @@ -554,7 +561,12 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] { folderLabel: recentThreadLabel ? `${items.length} 个线程 · 最近:${recentThreadLabel}` : `${items.length} 个线程`, folderKey, threadCount: items.length, - searchAliases: buildFolderSearchAliases(items), + ...(searchAliases + ? { + searchAliases: searchAliases.aliases, + searchTargetProjectIds: searchAliases.targetProjectIds, + } + : {}), preview: latestItem.preview || `包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}》`, lastMessagePreview: diff --git a/tests/conversation-home-items.test.ts b/tests/conversation-home-items.test.ts index c5edf5f..e7b581c 100644 --- a/tests/conversation-home-items.test.ts +++ b/tests/conversation-home-items.test.ts @@ -459,9 +459,16 @@ test("folder archive search aliases keep full reachability across five threads", "网络修复", "审阅确认", ]); + assert.deepEqual(folder?.searchTargetProjectIds, [ + "boss-thread-1", + "boss-thread-2", + "boss-thread-3", + "boss-thread-4", + "boss-thread-5", + ]); }); -test("folder archive search aliases still dedupe when multiple threads share the same title", async () => { +test("folder archive search aliases keep same-label threads reachable through every project id", async () => { await setup(); const state = await readState(); @@ -476,7 +483,8 @@ test("folder archive search aliases still dedupe when multiple threads share the const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive"); assert.ok(folder, "expected grouped folder archive item"); - assert.deepEqual(folder?.searchAliases, ["发布回滚", "日志收口", "网络修复"]); + assert.deepEqual(folder?.searchAliases, ["发布回滚", "发布回滚", "日志收口", "网络修复"]); + assert.deepEqual(folder?.searchTargetProjectIds, ["boss-thread-1", "boss-thread-2", "boss-thread-3", "boss-thread-4"]); }); test("conversation items expose context status while keeping idle activity silent", async () => {