feat: add wechat style native message forwarding

This commit is contained in:
kris
2026-03-28 08:39:08 +08:00
parent 200fc18210
commit 7109f1d3db
5 changed files with 464 additions and 12 deletions

View File

@@ -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(

View File

@@ -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;
}
}

View File

@@ -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<Intent> conversationInfoLauncher;
private ActivityResultLauncher<Intent> 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<String> 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 "语音";

View File

@@ -124,6 +124,7 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/project_chat_composer_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
@@ -163,4 +164,28 @@
android:textColor="@color/boss_surface"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/project_chat_multi_select_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="12dp"
android:visibility="gone">
<Button
android:id="@+id/project_chat_multi_forward"
android:layout_width="match_parent"
android:layout_height="44dp"
android:background="@drawable/bg_primary_button"
android:text="转发"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -72,4 +72,14 @@ public class ProjectChatUiStateTest {
assertEquals("messageId must not be blank", expected.getMessage());
}
}
@Test
public void singleForwardMessageUsesSingleModeLabel() {
assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single"));
}
@Test
public void bundleForwardMessageUsesBundleModeLabel() {
assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle"));
}
}