From 7109f1d3db629a7b014bea24f54f67831d39f6e1 Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 28 Mar 2026 08:39:08 +0800 Subject: [PATCH] feat: add wechat style native message forwarding --- .../src/main/java/com/hyzq/boss/BossUi.java | 114 +++++++ .../com/hyzq/boss/ProjectChatUiState.java | 15 + .../com/hyzq/boss/ProjectDetailActivity.java | 312 +++++++++++++++++- .../main/res/layout/activity_project_chat.xml | 25 ++ .../com/hyzq/boss/ProjectChatUiStateTest.java | 10 + 5 files changed, 464 insertions(+), 12 deletions(-) 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 4f2e1f4..67a28fc 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -723,6 +723,107 @@ public final class BossUi { return wrapper; } + public static LinearLayout buildForwardSingleBubble( + Context context, + String senderLabel, + String body, + @Nullable String meta, + @Nullable String sourceLabel, + boolean outgoing + ) { + LinearLayout wrapper = buildMessageBubble( + context, + senderLabel, + body, + meta, + outgoing, + ProjectChatUiState.labelForForwardKind("forward_single") + ); + if (wrapper.getChildCount() < 2 || !(wrapper.getChildAt(1) instanceof LinearLayout)) { + return wrapper; + } + LinearLayout bubble = (LinearLayout) wrapper.getChildAt(1); + if (!TextUtils.isEmpty(sourceLabel)) { + TextView sourceView = new TextView(context); + sourceView.setText("来自 " + sourceLabel); + sourceView.setTextSize(12); + sourceView.setTextColor(context.getColor(outgoing ? R.color.boss_surface : R.color.boss_text_muted)); + sourceView.setAlpha(outgoing ? 0.85f : 1f); + sourceView.setPadding(0, 0, 0, dp(context, 8)); + bubble.addView(sourceView, Math.min(1, bubble.getChildCount())); + } + return wrapper; + } + + public static LinearLayout buildForwardBundleCard( + Context context, + String senderLabel, + String cardTitle, + String summary, + @Nullable String meta, + boolean outgoing + ) { + LinearLayout wrapper = new LinearLayout(context); + wrapper.setOrientation(LinearLayout.VERTICAL); + wrapper.setGravity(outgoing ? Gravity.END : Gravity.START); + LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + wrapperParams.bottomMargin = dp(context, 12); + wrapper.setLayoutParams(wrapperParams); + + TextView metaView = new TextView(context); + String metaText = senderLabel; + if (!TextUtils.isEmpty(meta)) { + metaText = metaText + " · " + meta; + } + metaView.setText(metaText); + metaView.setTextSize(11); + metaView.setTextColor(context.getColor(R.color.boss_text_soft)); + metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4)); + wrapper.addView(metaView); + + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + card.setMinimumWidth(dp(context, 180)); + card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12)); + card.setBackground(createRoundedBackground( + outgoing ? Color.parseColor("#CFF0D8") : Color.WHITE, + dp(context, 18) + )); + card.setElevation(dp(context, 1)); + + TextView kindView = new TextView(context); + kindView.setText(ProjectChatUiState.labelForForwardKind("forward_bundle")); + kindView.setTextSize(11); + kindView.setTypeface(Typeface.DEFAULT_BOLD); + kindView.setTextColor(context.getColor(R.color.boss_text_muted)); + card.addView(kindView); + + TextView titleView = new TextView(context); + titleView.setText(TextUtils.isEmpty(cardTitle) ? "聊天记录" : cardTitle); + titleView.setTextSize(15); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + titleView.setPadding(0, dp(context, 6), 0, 0); + titleView.setMaxLines(2); + titleView.setEllipsize(TextUtils.TruncateAt.END); + card.addView(titleView); + + TextView summaryView = new TextView(context); + summaryView.setText(TextUtils.isEmpty(summary) ? "转发的聊天记录" : summary); + summaryView.setTextSize(13); + summaryView.setLineSpacing(0f, 1.2f); + summaryView.setTextColor(context.getColor(R.color.boss_text_muted)); + summaryView.setPadding(0, dp(context, 8), 0, 0); + summaryView.setMaxWidth(Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.72f)); + card.addView(summaryView); + + wrapper.addView(card); + return wrapper; + } + public static LinearLayout buildPendingOutgoingMessageBubble( Context context, String senderLabel, @@ -732,6 +833,19 @@ public final class BossUi { return buildMessageBubble(context, effectiveSender, body, "发送中", true, null); } + public static void applyMessageSelectionState(Context context, View messageView, boolean selected) { + if (messageView == null) { + return; + } + if (selected) { + messageView.setBackground(createRoundedBackground(Color.parseColor("#EAF7F0"), dp(context, 20))); + messageView.setAlpha(1f); + } else { + messageView.setBackgroundColor(Color.TRANSPARENT); + messageView.setAlpha(1f); + } + } + public static TextView buildMessagePlaceholder(Context context, String text) { TextView placeholder = new TextView(context); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index 449fdbf..245caf0 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -32,6 +32,10 @@ public final class ProjectChatUiState { return new SelectionState(new LinkedHashSet<>()); } + public static SelectionState selectOnly(String messageId) { + return toggleSelection(emptySelection(), messageId); + } + public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) { if (messageId == null || messageId.trim().isEmpty()) { throw new IllegalArgumentException("messageId must not be blank"); @@ -49,4 +53,15 @@ public final class ProjectChatUiState { public static boolean canForwardSelection(@Nullable SelectionState state) { return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2; } + + @Nullable + public static String labelForForwardKind(@Nullable String kind) { + if ("forward_single".equals(kind)) { + return "转发"; + } + if ("forward_bundle".equals(kind)) { + return "聊天记录"; + } + return null; + } } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 06f80b0..0e1fc80 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -1,5 +1,7 @@ package com.hyzq.boss; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Intent; import android.os.Bundle; import android.text.Editable; @@ -14,10 +16,14 @@ import android.widget.ScrollView; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import org.json.JSONArray; import org.json.JSONObject; +import java.util.ArrayList; +import java.util.List; + public class ProjectDetailActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_NAME = "project_name"; @@ -27,14 +33,22 @@ public class ProjectDetailActivity extends BossScreenActivity { private boolean projectIsGroup; private String projectFolderName; private LinearLayout quickActionsLayout; + private LinearLayout composerRow; + private LinearLayout multiSelectActionsLayout; private EditText composerInput; private Button composerSendButton; + private Button multiSelectForwardButton; private ScrollView chatScrollView; private View pendingOutgoingBubble; private boolean composerSending; private boolean renderNearBottom; private boolean renderForcedScrollToBottom; + private boolean conversationInfoReady; + private String currentScreenTitle; + private String currentScreenSubtitle; + private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection(); private ActivityResultLauncher conversationInfoLauncher; + private ActivityResultLauncher forwardTargetLauncher; @Override protected int getLayoutResId() { @@ -47,8 +61,11 @@ public class ProjectDetailActivity extends BossScreenActivity { projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); initialProjectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); quickActionsLayout = findViewById(R.id.project_chat_quick_actions); + composerRow = findViewById(R.id.project_chat_composer_row); + multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions); composerInput = findViewById(R.id.project_chat_input); composerSendButton = findViewById(R.id.project_chat_send); + multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward); chatScrollView = findViewById(R.id.project_chat_scroll); conversationInfoLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), @@ -61,15 +78,32 @@ public class ProjectDetailActivity extends BossScreenActivity { String updatedTitle = data.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME); if (!TextUtils.isEmpty(updatedTitle)) { initialProjectName = updatedTitle; - configureScreen(updatedTitle, "正在同步项目详情..."); + updateProjectHeader(updatedTitle, "正在同步项目详情..."); } } reload(); } ); + forwardTargetLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() != RESULT_OK) { + return; + } + exitMultiSelect(); + reload(true); + } + ); - configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); + updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer()); + multiSelectForwardButton.setOnClickListener(v -> { + if (!ProjectChatUiState.canForwardSelection(selectionState)) { + showMessage("至少选择两条消息后才能合并转发"); + return; + } + openBundleForwardTarget(new ArrayList<>(selectionState.selectedMessageIds)); + }); composerInput.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @@ -83,6 +117,7 @@ public class ProjectDetailActivity extends BossScreenActivity { public void afterTextChanged(Editable s) {} }); updateComposerSendButtonState(); + updateSelectionUi(); reload(true); } @@ -129,6 +164,7 @@ public class ProjectDetailActivity extends BossScreenActivity { composerInput.setEnabled(!refreshing); } updateComposerSendButtonState(); + updateSelectionUi(); } private void renderProject(JSONObject payload) { @@ -140,8 +176,8 @@ public class ProjectDetailActivity extends BossScreenActivity { initialProjectName = title; projectIsGroup = project != null && project.optBoolean("isGroup", false); projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); - configureScreen(title, buildProjectSubtitle(projectFolderName, devices)); - setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo()); + conversationInfoReady = project != null; + updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices)); renderQuickActions(); replaceContent(); @@ -154,20 +190,14 @@ public class ProjectDetailActivity extends BossScreenActivity { if (message == null) { continue; } - appendContent(BossUi.buildMessageBubble( - this, - message.optString("senderLabel", "消息"), - message.optString("body", ""), - formatMessageTime(message.optString("sentAt", "")), - isOutgoingMessage(message.optString("senderLabel", "")), - labelForMessageKind(message.optString("kind", "")) - )); + appendContent(buildMessageView(message)); } } else { appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。")); } setRefreshing(false); + updateSelectionUi(); if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) { scrollChatToBottom(); } @@ -278,6 +308,217 @@ public class ProjectDetailActivity extends BossScreenActivity { conversationInfoLauncher.launch(intent); } + private View buildMessageView(JSONObject message) { + String messageId = message.optString("id", ""); + String senderLabel = message.optString("senderLabel", "消息"); + String body = message.optString("body", ""); + String meta = formatMessageTime(message.optString("sentAt", "")); + String kind = message.optString("kind", ""); + boolean outgoing = isOutgoingMessage(senderLabel); + + View messageView; + switch (kind) { + case "forward_single": + messageView = BossUi.buildForwardSingleBubble( + this, + senderLabel, + body, + meta, + resolveForwardSingleSourceLabel(message), + outgoing + ); + break; + case "forward_bundle": + messageView = BossUi.buildForwardBundleCard( + this, + senderLabel, + resolveForwardBundleTitle(message), + resolveForwardBundleSummary(message), + meta, + outgoing + ); + break; + default: + messageView = BossUi.buildMessageBubble( + this, + senderLabel, + body, + meta, + outgoing, + labelForMessageKind(kind) + ); + break; + } + bindMessageInteractions(messageView, messageId, body); + return messageView; + } + + private void bindMessageInteractions(View messageView, String messageId, String body) { + if (messageView == null || TextUtils.isEmpty(messageId)) { + return; + } + messageView.setTag(messageId); + messageView.setClickable(true); + messageView.setLongClickable(true); + messageView.setOnClickListener(v -> { + if (!selectionState.multiSelecting) { + return; + } + toggleMultiSelectMessage(messageId); + }); + messageView.setOnLongClickListener(v -> { + showMessageActions(messageId, body); + return true; + }); + BossUi.applyMessageSelectionState( + this, + messageView, + selectionState.selectedMessageIds.contains(messageId) + ); + } + + private void showMessageActions(String messageId, String body) { + new AlertDialog.Builder(this) + .setTitle("消息操作") + .setItems(new CharSequence[]{"转发", "多选", "复制", "删除", "取消"}, (dialog, which) -> { + switch (which) { + case 0: + openSingleForwardTarget(messageId); + break; + case 1: + enterMultiSelectFromMessage(messageId); + break; + case 2: + copyMessageBody(body); + break; + case 3: + showMessage("删除消息能力暂未接通"); + break; + default: + dialog.dismiss(); + break; + } + }) + .show(); + } + + private void copyMessageBody(String body) { + ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard == null) { + showMessage("当前设备不支持复制"); + return; + } + clipboard.setPrimaryClip(ClipData.newPlainText("boss-message", TextUtils.isEmpty(body) ? "" : body)); + showMessage("已复制消息"); + } + + private void openSingleForwardTarget(String sourceMessageId) { + if (TextUtils.isEmpty(sourceMessageId)) { + showMessage("缺少消息 ID"); + return; + } + Intent intent = new Intent(this, ForwardTargetActivity.class); + intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId); + intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single"); + intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_MESSAGE_ID, sourceMessageId); + forwardTargetLauncher.launch(intent); + } + + private void openBundleForwardTarget(List sourceMessageIds) { + if (sourceMessageIds == null || sourceMessageIds.size() < 2) { + showMessage("至少选择两条消息后才能合并转发"); + return; + } + Intent intent = new Intent(this, ForwardTargetActivity.class); + intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId); + intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "bundle"); + intent.putExtra( + ForwardTargetActivity.EXTRA_SOURCE_MESSAGE_IDS, + sourceMessageIds.toArray(new String[0]) + ); + forwardTargetLauncher.launch(intent); + } + + private void enterMultiSelectFromMessage(String messageId) { + selectionState = ProjectChatUiState.selectOnly(messageId); + updateSelectionUi(); + } + + private void exitMultiSelect() { + selectionState = ProjectChatUiState.emptySelection(); + updateSelectionUi(); + } + + private void toggleMultiSelectMessage(String messageId) { + ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(selectionState, messageId); + if (!next.multiSelecting) { + exitMultiSelect(); + return; + } + selectionState = next; + updateSelectionUi(); + } + + private void updateSelectionUi() { + boolean multiSelecting = selectionState != null && selectionState.multiSelecting; + if (composerRow != null) { + composerRow.setVisibility(multiSelecting ? View.GONE : View.VISIBLE); + } + if (multiSelectActionsLayout != null) { + multiSelectActionsLayout.setVisibility(multiSelecting ? View.VISIBLE : View.GONE); + } + if (multiSelectForwardButton != null) { + multiSelectForwardButton.setEnabled(!isComposerBusy() && ProjectChatUiState.canForwardSelection(selectionState)); + } + if (refreshLayout != null) { + refreshLayout.setEnabled(!multiSelecting); + } + + if (multiSelecting) { + backButton.setText("取消"); + backButton.setOnClickListener(v -> exitMultiSelect()); + refreshButton.setVisibility(View.GONE); + hideHeaderAction(); + titleView.setText("已选 " + selectionState.selectedMessageIds.size() + " 条"); + subtitleView.setText("选择要转发的消息"); + } else { + backButton.setText("返回"); + backButton.setOnClickListener(v -> finish()); + refreshButton.setVisibility(View.VISIBLE); + titleView.setText(TextUtils.isEmpty(currentScreenTitle) ? "项目详情" : currentScreenTitle); + subtitleView.setText(TextUtils.isEmpty(currentScreenSubtitle) ? "原生页面" : currentScreenSubtitle); + if (conversationInfoReady) { + setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo()); + } else { + hideHeaderAction(); + } + } + refreshMessageSelectionViews(); + } + + private void refreshMessageSelectionViews() { + if (contentLayout == null) { + return; + } + for (int i = 0; i < contentLayout.getChildCount(); i++) { + View child = contentLayout.getChildAt(i); + Object tag = child.getTag(); + boolean selected = tag instanceof String + && selectionState != null + && selectionState.selectedMessageIds.contains(tag); + BossUi.applyMessageSelectionState(this, child, selected); + } + } + + private void updateProjectHeader(String title, String subtitle) { + currentScreenTitle = title; + currentScreenSubtitle = subtitle; + if (selectionState != null && selectionState.multiSelecting) { + return; + } + configureScreen(title, subtitle); + } + private String joinDeviceNames(@Nullable JSONArray devices) { if (devices == null || devices.length() == 0) { return "未绑定设备"; @@ -379,11 +620,58 @@ public class ProjectDetailActivity extends BossScreenActivity { return sentAt; } + private String resolveForwardSingleSourceLabel(JSONObject message) { + JSONObject forwardSource = message.optJSONObject("forwardSource"); + if (forwardSource == null) { + return ""; + } + String threadTitle = forwardSource.optString("sourceThreadTitle", ""); + if (!TextUtils.isEmpty(threadTitle)) { + return threadTitle; + } + return forwardSource.optString("sourceProjectName", ""); + } + + private String resolveForwardBundleTitle(JSONObject message) { + JSONObject forwardBundle = message.optJSONObject("forwardBundle"); + if (forwardBundle == null) { + return "聊天记录"; + } + String threadTitle = forwardBundle.optString("sourceThreadTitle", ""); + if (!TextUtils.isEmpty(threadTitle)) { + return threadTitle; + } + String projectName = forwardBundle.optString("sourceProjectName", ""); + return TextUtils.isEmpty(projectName) ? "聊天记录" : projectName; + } + + private String resolveForwardBundleSummary(JSONObject message) { + JSONObject forwardBundle = message.optJSONObject("forwardBundle"); + if (forwardBundle == null) { + return message.optString("body", "转发的聊天记录"); + } + int itemCount = forwardBundle.optInt("itemCount", 0); + JSONArray items = forwardBundle.optJSONArray("items"); + JSONObject lastItem = items == null || items.length() == 0 ? null : items.optJSONObject(items.length() - 1); + String lastBody = lastItem == null ? "" : lastItem.optString("body", ""); + if (itemCount > 0 && !TextUtils.isEmpty(lastBody)) { + return itemCount + " 条消息 · 最后一条:" + lastBody; + } + if (itemCount > 0) { + return itemCount + " 条消息"; + } + return message.optString("body", "转发的聊天记录"); + } + @Nullable private String labelForMessageKind(String kind) { if (TextUtils.isEmpty(kind) || "text".equals(kind)) { return null; } + String forwardLabel = ProjectChatUiState.labelForForwardKind(kind); + if (!TextUtils.isEmpty(forwardLabel)) { + return forwardLabel; + } switch (kind) { case "voice_intent": return "语音"; diff --git a/android/app/src/main/res/layout/activity_project_chat.xml b/android/app/src/main/res/layout/activity_project_chat.xml index f3fee5f..f98a56f 100644 --- a/android/app/src/main/res/layout/activity_project_chat.xml +++ b/android/app/src/main/res/layout/activity_project_chat.xml @@ -124,6 +124,7 @@ + + + +