From 6c786309abe1109f1d20fb7d1066446f49160426 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 06:13:41 +0000 Subject: [PATCH 1/4] Initial plan From 85b0e00e45a886226bc920beaf0a7a35a5735e9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 06:27:18 +0000 Subject: [PATCH 2/4] Implement reply sorting by popularity and threaded reply support Co-authored-by: graycreate <5203798+graycreate@users.noreply.github.com> --- .../main/java/me/ghui/v2er/general/Pref.java | 8 ++ .../me/ghui/v2er/general/ReplySortMode.java | 35 ++++++ .../ghui/v2er/module/topic/TopicActivity.java | 33 ++++- .../module/topic/TopicReplyItemDelegate.java | 32 +++++ .../me/ghui/v2er/network/bean/TopicInfo.java | 118 +++++++++++++++++- .../drawable/threaded_reply_background.xml | 9 ++ .../main/res/menu/topic_info_toolbar_menu.xml | 4 + app/src/main/res/values/ids.xml | 1 + app/src/main/res/values/keys.xml | 1 + app/src/main/res/values/strings.xml | 5 +- 10 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/me/ghui/v2er/general/ReplySortMode.java create mode 100644 app/src/main/res/drawable/threaded_reply_background.xml diff --git a/app/src/main/java/me/ghui/v2er/general/Pref.java b/app/src/main/java/me/ghui/v2er/general/Pref.java index db62ced8..0005ec87 100644 --- a/app/src/main/java/me/ghui/v2er/general/Pref.java +++ b/app/src/main/java/me/ghui/v2er/general/Pref.java @@ -30,6 +30,10 @@ public static int readInt(String key, int defaultValue) { return Prefs.with(app()).readInt(key, defaultValue); } + public static int readInt(@StringRes int key, int defaultValue) { + return readInt(app().getString(key), defaultValue); + } + public static boolean readBool(@StringRes int key) { return readBool(app().getString(key)); } @@ -66,6 +70,10 @@ public static void save(String key, int value) { Prefs.with(app()).writeInt(key, value); } + public static void save(@StringRes int key, int value) { + Prefs.with(app()).writeInt(app().getString(key), value); + } + public static void save(String key, boolean value) { Prefs.with(app()).writeBoolean(key, value); } diff --git a/app/src/main/java/me/ghui/v2er/general/ReplySortMode.java b/app/src/main/java/me/ghui/v2er/general/ReplySortMode.java new file mode 100644 index 00000000..80ad5ed5 --- /dev/null +++ b/app/src/main/java/me/ghui/v2er/general/ReplySortMode.java @@ -0,0 +1,35 @@ +package me.ghui.v2er.general; + +/** + * Reply sorting modes for topic replies + * Created for issue #41 - adding reply sorting by popularity + */ +public enum ReplySortMode { + BY_TIME(0, "按时间排序"), + BY_POPULARITY(1, "按热门排序"); + + private final int value; + private final String description; + + ReplySortMode(int value, String description) { + this.value = value; + this.description = description; + } + + public int getValue() { + return value; + } + + public String getDescription() { + return description; + } + + public static ReplySortMode fromValue(int value) { + for (ReplySortMode mode : values()) { + if (mode.value == value) { + return mode; + } + } + return BY_TIME; // default + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java b/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java index efd0d26e..84cbe3d8 100644 --- a/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java +++ b/app/src/main/java/me/ghui/v2er/module/topic/TopicActivity.java @@ -130,6 +130,9 @@ public void onMapSharedElements(List names, Map sharedElem private boolean mIsHideReplyBtn; private boolean mIsLogin = UserUtils.isLogin(); private boolean mIsScanInOrder = !Pref.readBool(R.string.pref_key_is_scan_in_reverse, false); + private me.ghui.v2er.general.ReplySortMode mReplySortMode = me.ghui.v2er.general.ReplySortMode.fromValue( + Pref.readInt(R.string.pref_key_reply_sort_mode, me.ghui.v2er.general.ReplySortMode.BY_TIME.getValue())); + private MenuItem mReplySortMenuItem; /** * @param topicId @@ -222,6 +225,8 @@ protected void configToolBar(BaseToolBar toolBar) { replyMenuItem.setVisible(mIsHideReplyBtn); MenuItem scanOrderMenuItem = menu.findItem(R.id.action_scan_order); scanOrderMenuItem.setTitle(mIsScanInOrder ? "顺序浏览" : "逆序浏览"); + mReplySortMenuItem = menu.findItem(R.id.action_reply_sort); + mReplySortMenuItem.setTitle(mReplySortMode.getDescription()); mToolbar.setOnMenuItemClickListener(item -> { if (mTopicInfo == null) { if (item.getItemId() == R.id.action_open_in_browser) { @@ -321,6 +326,18 @@ protected void configToolBar(BaseToolBar toolBar) { loadFromStart(); showLoading(); break; + case R.id.action_reply_sort: + // Toggle reply sorting mode + mReplySortMode = (mReplySortMode == me.ghui.v2er.general.ReplySortMode.BY_TIME) + ? me.ghui.v2er.general.ReplySortMode.BY_POPULARITY + : me.ghui.v2er.general.ReplySortMode.BY_TIME; + mReplySortMenuItem.setTitle(mReplySortMode.getDescription()); + Pref.save(R.string.pref_key_reply_sort_mode, mReplySortMode.getValue()); + // Re-apply sorting to current data without reloading from network + if (mTopicInfo != null) { + refreshReplySorting(); + } + break; } return true; }); @@ -598,7 +615,7 @@ public void fillView(TopicInfo topicInfo, int page) { } // TODO: 2019-06-23 save info from adapter - mAdapter.setData(topicInfo.getItems(isLoadMore, mIsScanInOrder), isLoadMore); + mAdapter.setData(topicInfo.getItems(isLoadMore, mIsScanInOrder, mReplySortMode), isLoadMore); if (!topicInfo.getContentInfo().isValid()) { onRenderCompleted(); } @@ -656,6 +673,20 @@ private void autoScroll() { } } + private void refreshReplySorting() { + if (mTopicInfo == null) return; + + // Get current page info to maintain state + int currentPage = mPresenter.getPage(); + boolean isLoadMore = mIsScanInOrder ? currentPage > 1 : currentPage != mTopicInfo.getTotalPage(); + + // Re-sort and update adapter with current sorting mode + mAdapter.setData(mTopicInfo.getItems(isLoadMore, mIsScanInOrder, mReplySortMode), isLoadMore); + + // Update the @mention list as well + fillAtList(); + } + private void fillAtList() { List datum = mAdapter.getDatas(); if (repliersInfo == null) { diff --git a/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java b/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java index e10c6f8b..f1c540f5 100644 --- a/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java +++ b/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java @@ -60,6 +60,9 @@ public void convert(ViewHolder holder, TopicInfo.Item item, int position) { } else { holder.getConvertView().setBackgroundColor(Color.TRANSPARENT); } + + // Apply indentation for threaded replies + applyThreadIndentation(holder, replyInfo); holder.setText(R.id.reply_user_name_tv, replyInfo.getUserName()); if (replyInfo.getLove() == 0) { holder.getView(R.id.reply_thx_tv).setVisibility(View.GONE); @@ -117,4 +120,33 @@ public boolean onUrlClick(String url) { } } + /** + * Apply indentation for threaded replies based on mention depth + */ + private void applyThreadIndentation(ViewHolder holder, TopicInfo.Reply replyInfo) { + View convertView = holder.getConvertView(); + + // Calculate indentation based on thread level + int indentLevel = replyInfo.getIndentLevel(); + int baseIndent = (int) mContext.getResources().getDimension(R.dimen.common_padding_size); + int indentPixels = baseIndent + (indentLevel * baseIndent / 2); // 12dp base + 6dp per level + + // Apply left margin to indicate thread level + if (convertView.getLayoutParams() instanceof android.view.ViewGroup.MarginLayoutParams) { + android.view.ViewGroup.MarginLayoutParams params = + (android.view.ViewGroup.MarginLayoutParams) convertView.getLayoutParams(); + params.leftMargin = indentPixels; + convertView.setLayoutParams(params); + } + + // Visual indicator for threaded replies + if (indentLevel > 0 && replyInfo.hasMentions()) { + // Add a subtle visual indicator for threaded replies + convertView.setBackgroundResource(R.drawable.threaded_reply_background); + } else if (!replyInfo.isOwner() || !Pref.readBool(R.string.pref_key_highlight_topic_owner_reply_item)) { + // Reset background for non-threaded replies + convertView.setBackgroundColor(Color.TRANSPARENT); + } + } + } diff --git a/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java b/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java index ebd79b36..30c4ffa9 100644 --- a/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java +++ b/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java @@ -123,6 +123,18 @@ public ContentInfo getContentInfo() { * @return */ public List getItems(boolean isLoadMore, boolean isInOrder) { + return getItems(isLoadMore, isInOrder, me.ghui.v2er.general.ReplySortMode.BY_TIME); + } + + /** + * 加载分页后的数据 + * + * @param isLoadMore + * @param isInOrder 是否是正序加载 + * @param sortMode 回复排序模式 + * @return + */ + public List getItems(boolean isLoadMore, boolean isInOrder, me.ghui.v2er.general.ReplySortMode sortMode) { if (items == null) { items = new ArrayList<>(Utils.listSize(replies) + 2); } else { @@ -145,7 +157,25 @@ public List getItems(boolean isLoadMore, boolean isInOrder) { for (Reply reply : replies) { reply.setOwner(owner); } - items.addAll(replies); + + // Parse mentions and calculate threading levels + processMentionsAndThreading(replies); + + // Apply sorting based on mode + List sortedReplies = new ArrayList<>(replies); + if (sortMode == me.ghui.v2er.general.ReplySortMode.BY_POPULARITY) { + // Sort by love count (popularity) in descending order, then by floor for stable sort + Collections.sort(sortedReplies, (r1, r2) -> { + int loveCompare = Integer.compare(r2.getLove(), r1.getLove()); + if (loveCompare != 0) { + return loveCompare; + } + // If love count is equal, sort by floor for stable ordering + return Integer.compare(r1.floor, r2.floor); + }); + } + + items.addAll(sortedReplies); } return items; } @@ -166,6 +196,68 @@ public HeaderInfo getHeaderInfo() { return headerInfo; } + /** + * Process @mentions in replies and calculate threading levels + * @param replies List of replies to process + */ + private void processMentionsAndThreading(List replies) { + if (replies == null || replies.isEmpty()) return; + + // Create a map of username to reply for quick lookup + Map userToLatestReply = new HashMap<>(); + + for (Reply reply : replies) { + // Parse @mentions from reply content + List mentions = extractMentions(reply.getReplyContent()); + reply.setMentionedUsers(mentions); + + // Calculate indent level based on mentions + int maxIndentLevel = 0; + for (String mentionedUser : mentions) { + Reply mentionedReply = userToLatestReply.get(mentionedUser); + if (mentionedReply != null) { + // This reply is responding to another reply, increase indent + maxIndentLevel = Math.max(maxIndentLevel, mentionedReply.getIndentLevel() + 1); + } + } + reply.setIndentLevel(maxIndentLevel); + + // Update the latest reply for this user + userToLatestReply.put(reply.getUserName(), reply); + } + } + + /** + * Extract @username mentions from reply content + * @param content HTML content of the reply + * @return List of mentioned usernames + */ + private List extractMentions(String content) { + List mentions = new ArrayList<>(); + if (Check.isEmpty(content)) return mentions; + + // Use regex to find @username patterns + // V2EX format: @username or @username + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile( + "@(?:]*href=\"/member/([^\"]+)\"[^>]*>([^<]+)|([a-zA-Z0-9_\\-]+))" + ); + java.util.regex.Matcher matcher = pattern.matcher(content); + + while (matcher.find()) { + String username = matcher.group(1); // From href + if (username == null) { + username = matcher.group(3); // Direct @username + } + if (username != null && !mentions.contains(username)) { + mentions.add(username); + } + } + + return mentions; + } + return headerInfo; + } + public void setHeaderInfo(HeaderInfo headerInfo) { this.headerInfo = headerInfo; } @@ -496,6 +588,9 @@ public static class Reply implements Item { @Pick(attr = "id") private String replyId; private boolean isOwner = false; + // Threading support for 楼中楼 functionality + private List mentionedUsers = new ArrayList<>(); + private int indentLevel = 0; public boolean isOwner() { return isOwner; @@ -610,6 +705,27 @@ public boolean isSelf() { return false; } } + + // Threading support methods + public List getMentionedUsers() { + return mentionedUsers; + } + + public void setMentionedUsers(List mentionedUsers) { + this.mentionedUsers = mentionedUsers != null ? mentionedUsers : new ArrayList<>(); + } + + public int getIndentLevel() { + return indentLevel; + } + + public void setIndentLevel(int indentLevel) { + this.indentLevel = Math.max(0, Math.min(indentLevel, 3)); // Max 3 levels + } + + public boolean hasMentions() { + return mentionedUsers != null && !mentionedUsers.isEmpty(); + } } } diff --git a/app/src/main/res/drawable/threaded_reply_background.xml b/app/src/main/res/drawable/threaded_reply_background.xml new file mode 100644 index 00000000..512e13a3 --- /dev/null +++ b/app/src/main/res/drawable/threaded_reply_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/topic_info_toolbar_menu.xml b/app/src/main/res/menu/topic_info_toolbar_menu.xml index db60c61a..c4f02691 100644 --- a/app/src/main/res/menu/topic_info_toolbar_menu.xml +++ b/app/src/main/res/menu/topic_info_toolbar_menu.xml @@ -39,6 +39,10 @@ android:id="@id/action_scan_order" android:icon="@drawable/love_normal_icon" android:title="逆序浏览" /> + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 8c043c84..55c70596 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -12,6 +12,7 @@ + diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 36caeaee..6ae98eb1 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -19,6 +19,7 @@ pref_key_shortcuts_back_to_home pref_key_auto_checkin is_scan_in_reverse + pref_key_reply_sort_mode pref_key_auto_dark_mode_settings pref_key_auto_daynight_switch diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e588f52..f29bc200 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,9 @@ 搜索输入框 清除搜索输入框 - + + 按时间排序 + 按热门排序 + 楼中楼回复 From dc7c95b38b2f0f7afcbb8c376caf0fc7d5f1ab7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 06:31:09 +0000 Subject: [PATCH 3/4] Add unit tests, better icons, visual indicators and documentation for reply features Co-authored-by: graycreate <5203798+graycreate@users.noreply.github.com> --- FEATURES.md | 76 +++++++++++++++++ .../module/topic/TopicReplyItemDelegate.java | 8 +- .../me/ghui/v2er/network/bean/TopicInfo.java | 12 ++- app/src/main/res/drawable/ic_sort.xml | 10 +++ .../main/res/menu/topic_info_toolbar_menu.xml | 2 +- .../ghui/v2er/network/bean/TopicInfoTest.java | 81 +++++++++++++++++++ 6 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 FEATURES.md create mode 100644 app/src/main/res/drawable/ic_sort.xml create mode 100644 app/src/test/java/me/ghui/v2er/network/bean/TopicInfoTest.java diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 00000000..d1db4012 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,76 @@ +# V2er 回复排序和楼中楼功能说明 + +## 功能概述 + +本次更新为 V2er 添加了两个重要功能: + +### 1. 回复按热门程度排序 (Reply Sorting by Popularity) + +用户现在可以在主题页面切换回复的排序方式: +- **按时间排序** (默认) - 按回复时间顺序显示 +- **按热门排序** - 按感谢次数降序排列,感谢次数相同时按楼层排序 + +#### 使用方法: +1. 在主题页面点击右上角菜单 +2. 选择"按时间排序"或"按热门排序"来切换模式 +3. 设置会自动保存,下次打开时保持选择的排序方式 + +### 2. 楼中楼功能 (Threaded Replies) + +系统会自动检测回复中的 @用户名 提及,并以可视化方式显示回复的层级关系: + +#### 特性: +- 自动解析 `@username` 和 `@username` 格式 +- 根据提及关系计算缩进级别(最多3级) +- 有回复关系的评论会有: + - 左侧缩进显示层级关系 + - 楼层号后显示 💬 图标 + - 浅色背景区分 + +## 技术实现 + +### 核心类修改: + +1. **TopicInfo.java** + - 添加 `processMentionsAndThreading()` 方法处理提及解析 + - 添加 `extractMentions()` 方法使用正则表达式提取用户名 + - 扩展 `getItems()` 方法支持排序模式参数 + +2. **TopicActivity.java** + - 添加回复排序菜单项处理 + - 实现 `refreshReplySorting()` 方法即时切换排序 + - 集成排序模式状态管理 + +3. **TopicReplyItemDelegate.java** + - 添加 `applyThreadIndentation()` 方法应用缩进样式 + - 集成楼层显示和线程指示器 + +4. **Reply 类增强** + - 添加 `mentionedUsers` 列表存储提及的用户 + - 添加 `indentLevel` 字段控制缩进层级 + - 提供相关的 getter/setter 方法 + +### 新增组件: + +- **ReplySortMode.java** - 排序模式枚举类 +- **threaded_reply_background.xml** - 楼中楼回复背景样式 +- **ic_sort.xml** - 排序功能图标 + +## 用户体验改进 + +1. **直观的可视化** - 通过缩进和背景色清晰显示回复层级 +2. **状态持久化** - 排序偏好自动保存 +3. **性能优化** - 本地排序,无需重新加载网络数据 +4. **无侵入性** - 保持原有界面布局,仅添加新功能 + +## 示例场景 + +``` +1楼: 原始问题 +2楼: 直接回复问题 +3楼: @2楼用户 我也有同样问题 💬 [缩进显示] +4楼: @3楼用户 试试这个方法 💬 [进一步缩进] +5楼: 新的话题 +``` + +通过这种方式,用户可以更容易地跟踪对话线程和找到最受欢迎的回复。 \ No newline at end of file diff --git a/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java b/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java index f1c540f5..44960b41 100644 --- a/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java +++ b/app/src/main/java/me/ghui/v2er/module/topic/TopicReplyItemDelegate.java @@ -91,7 +91,13 @@ public void convert(ViewHolder holder, TopicInfo.Item item, int position) { } else { contentView.setVisibility(View.GONE); } - holder.setText(R.id.floor_tv, replyInfo.getFloor()); + + // Update floor text with threading indicator + String floorText = replyInfo.getFloor(); + if (replyInfo.hasMentions() && replyInfo.getIndentLevel() > 0) { + floorText += " 💬"; // Add emoji to indicate threaded reply + } + holder.setText(R.id.floor_tv, floorText); } public interface OnMemberClickListener { diff --git a/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java b/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java index 30c4ffa9..e425e968 100644 --- a/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java +++ b/app/src/main/java/me/ghui/v2er/network/bean/TopicInfo.java @@ -50,6 +50,10 @@ public class TopicInfo extends BaseInfo { private List items; + public TopicInfo() { + // Default constructor for testing and general use + } + public Problem getProblem() { return problem; } @@ -203,7 +207,7 @@ public HeaderInfo getHeaderInfo() { private void processMentionsAndThreading(List replies) { if (replies == null || replies.isEmpty()) return; - // Create a map of username to reply for quick lookup + // Create a map of username to their latest reply for threading calculation Map userToLatestReply = new HashMap<>(); for (Reply reply : replies) { @@ -232,7 +236,7 @@ private void processMentionsAndThreading(List replies) { * @param content HTML content of the reply * @return List of mentioned usernames */ - private List extractMentions(String content) { + List extractMentions(String content) { List mentions = new ArrayList<>(); if (Check.isEmpty(content)) return mentions; @@ -592,6 +596,10 @@ public static class Reply implements Item { private List mentionedUsers = new ArrayList<>(); private int indentLevel = 0; + public Reply() { + // Default constructor for testing and general use + } + public boolean isOwner() { return isOwner; } diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 00000000..2606f272 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/topic_info_toolbar_menu.xml b/app/src/main/res/menu/topic_info_toolbar_menu.xml index c4f02691..28823b8d 100644 --- a/app/src/main/res/menu/topic_info_toolbar_menu.xml +++ b/app/src/main/res/menu/topic_info_toolbar_menu.xml @@ -41,7 +41,7 @@ android:title="逆序浏览" /> mentions = topicInfo.extractMentions("@user1 thanks for the help!"); + assertEquals(1, mentions.size()); + assertEquals("user1", mentions.get(0)); + } + + @Test + public void testExtractMentions_LinkFormat() { + TopicInfo topicInfo = new TopicInfo(); + String content = "@user1 what do you think?"; + List mentions = topicInfo.extractMentions(content); + assertEquals(1, mentions.size()); + assertEquals("user1", mentions.get(0)); + } + + @Test + public void testExtractMentions_MultipleMentions() { + TopicInfo topicInfo = new TopicInfo(); + String content = "@user1 @user2 please check this out"; + List mentions = topicInfo.extractMentions(content); + assertEquals(2, mentions.size()); + assertTrue(mentions.contains("user1")); + assertTrue(mentions.contains("user2")); + } + + @Test + public void testExtractMentions_NoDuplicates() { + TopicInfo topicInfo = new TopicInfo(); + String content = "@user1 and @user1 again"; + List mentions = topicInfo.extractMentions(content); + assertEquals(1, mentions.size()); + assertEquals("user1", mentions.get(0)); + } + + @Test + public void testReplyIndentLevel() { + TopicInfo.Reply reply = new TopicInfo.Reply(); + + // Test default indent level + assertEquals(0, reply.getIndentLevel()); + + // Test setting indent level + reply.setIndentLevel(2); + assertEquals(2, reply.getIndentLevel()); + + // Test max indent level (should cap at 3) + reply.setIndentLevel(5); + assertEquals(3, reply.getIndentLevel()); + + // Test negative values (should set to 0) + reply.setIndentLevel(-1); + assertEquals(0, reply.getIndentLevel()); + } + + @Test + public void testReplyMentions() { + TopicInfo.Reply reply = new TopicInfo.Reply(); + + // Test empty mentions + assertFalse(reply.hasMentions()); + + // Test with mentions + reply.setMentionedUsers(Arrays.asList("user1", "user2")); + assertTrue(reply.hasMentions()); + assertEquals(2, reply.getMentionedUsers().size()); + } +} \ No newline at end of file From 059c369691fdd4843abc30e43c123009bcdf6a54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 06:31:40 +0000 Subject: [PATCH 4/4] Add UI mockup documentation showing visual changes --- UI_MOCKUP.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 UI_MOCKUP.md diff --git a/UI_MOCKUP.md b/UI_MOCKUP.md new file mode 100644 index 00000000..8a75f13a --- /dev/null +++ b/UI_MOCKUP.md @@ -0,0 +1,77 @@ +# UI Changes Mockup + +## Before and After Comparison + +### 1. Menu Bar Changes +``` +Before: +[★] [分享] [逆序浏览] [⋮] + +After: +[★] [分享] [逆序浏览] [≡] [⋮] + ↑ + New sort icon +``` + +### 2. Reply List - Time Sorting (Default) +``` +1楼 ghui ♥ 5 + This is the main question... + +2楼 user1 ♥ 2 + Here's my answer... + +3楼 user2 ♥ 8 + @user1 I agree with your solution +``` + +### 3. Reply List - Popularity Sorting +``` +3楼 user2 ♥ 8 + @user1 I agree with your solution + +1楼 ghui ♥ 5 + This is the main question... + +2楼 user1 ♥ 2 + Here's my answer... +``` + +### 4. Threaded Replies with Indentation +``` +1楼 ghui ♥ 5 + Original question here... + +2楼 alice ♥ 3 + First response... + + 3楼 bob 💬 ♥ 1 + @alice Good point! + [Subtle background, left margin] + + 4楼 charlie 💬 ♥ 0 + @bob Thanks for clarification + [More indented, different background] + +5楼 david ♥ 2 + New topic discussion... +``` + +## Visual Indicators + +1. **Threading Indicator**: 💬 emoji after floor number +2. **Indentation**: Progressive left margin for nested replies +3. **Background**: Subtle gray background for threaded replies +4. **Sorting**: Real-time toggle without page reload + +## User Flow + +1. User opens topic page → sees default time-based sorting +2. User taps sort icon → switches to popularity sorting +3. Replies reorder immediately showing most-thanked first +4. Threading relationships shown with visual indentation +5. User preference saved for future visits + +This provides both requested features: +- **按热门程度显示** (Display by popularity) ✅ +- **楼中楼** (Threaded replies) ✅ \ No newline at end of file